From da244355470752a2538bfc993c77692d854c0b47 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 22 Jan 2026 15:02:39 +0100 Subject: [PATCH 01/60] Added empty API_KEY to .env files --- .env | 7 +++++++ .env.test | 2 ++ 2 files changed, 9 insertions(+) diff --git a/.env b/.env index a92624c3..14faa72b 100644 --- a/.env +++ b/.env @@ -90,6 +90,13 @@ WEBDAV_PUBLIC_DIR='/webdav/public' # such as /webdav/homes for instance, so that users cannot access other users' homes. WEBDAV_HOMES_DIR= +# API +# When this variable is not empty, the /api endpoint becomes available. +# This endpoint allows admins to perform certain actions that are normally only available +# via the web dashboard. +# To generate a valid API_KEY you can use api:generate command +API_KEY= + # Logging path # By default, it will log in the standard Symfony directory: var/log/prod.log (for production) # You can use /dev/null here if you want to discard logs entirely diff --git a/.env.test b/.env.test index abacd241..198b65d0 100644 --- a/.env.test +++ b/.env.test @@ -8,3 +8,5 @@ PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots DATABASE_URL="mysql://davis:davis@127.0.0.1:3306/davis_test?serverVersion=10.9.3-MariaDB&charset=utf8mb4" MAILER_DSN=smtp://localhost:465?encryption=ssl&auth_mode=login&username=&password= + +API_KEY= \ No newline at end of file From 7f9fa762e58c07ec161cecb6ae84d189d5f9c903 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 22 Jan 2026 15:02:53 +0100 Subject: [PATCH 02/60] Added an API endpoint controller and placeholders for some api routes --- config/services.yaml | 4 + src/Command/ApiGenerateCommand.php | 42 ++++++++++ src/Controller/Api/ApiController.php | 117 +++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/Command/ApiGenerateCommand.php create mode 100644 src/Controller/Api/ApiController.php diff --git a/config/services.yaml b/config/services.yaml index c5830166..7039d2b8 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -78,6 +78,10 @@ services: arguments: $birthdayReminderOffset: "%birthday_reminder_offset%" + App\Controller\Api\ApiController: + arguments: + $apiKey: '%env(API_KEY)%' + when@dev: services: Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' diff --git a/src/Command/ApiGenerateCommand.php b/src/Command/ApiGenerateCommand.php new file mode 100644 index 00000000..fa00cf47 --- /dev/null +++ b/src/Command/ApiGenerateCommand.php @@ -0,0 +1,42 @@ +setName('api:generate') + ->setDescription('Generate a new API key') + ->setHelp('This command allows you to generate a new API key') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $apiKey = bin2hex(random_bytes(32)); + + $io->success($apiKey); + $io->warning('Set the API key in your .env file as API_KEY, as it won\'t be stored otherwise.'); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php new file mode 100644 index 00000000..859122b6 --- /dev/null +++ b/src/Controller/Api/ApiController.php @@ -0,0 +1,117 @@ +apiKey = $apiKey; + } + + private function validateApiKey(Request $request): bool + { + $key = $request->headers->get('X-API-Key'); + return hash_equals($this->apiKey, $key ?? ''); + } + + #[Route('/health', name: 'health', methods: ['GET'])] + public function healthCheck(Request $request): JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['error' => 'Unauthorized'], 401); + } + + return $this->json(['status' => 'OK'], 200); + } + + /** + * Retrieves a list of users. + * + * @param Request $request The HTTP GET request + * @return JsonResponse A JSON response containing the list of users or an error message + */ + #[Route('/users', name: 'users', methods: ['GET'])] + public function getUsers(Request $request): JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['error' => 'Unauthorized'], 401); + } + + // Dummy data for demonstration purposes + $users = [ + [ + "username" => "johndoe", + "displayname" => "John Doe", + "email" => "johndoe@example.com", + "uri" => "principals/johndoe" + ] + ]; + + $response = [ + "status" => "success", + "data" => $users + ]; + + return $this->json($response, 200); + } + + #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'])] + public function getUserCalendars(Request $request, string $username): JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['error' => 'Unauthorized'], 401); + } + + // Dummy data for demonstration purposes + $calendars = [ + [ + "displayname" => "Personal Calendar", + "uri" => "default", + "description" => "Work related events", + "events" => 1, + "notes" => 1, + "tasks" => 1 + ], + ]; + + $response = [ + "status" => "success", + "data" => $calendars + ]; + + return $this->json($response, 200); + } + + #[Route('/addressbooks/{username}', name: 'addressbooks', methods: ['GET'])] + public function getUserAddressBooks(Request $request, string $username): JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['error' => 'Unauthorized'], 401); + } + + // Dummy data for demonstration purposes + $addressBooks = [ + [ + "displayname" => "Personal Contacts", + "uri" => "default", + "description" => "My personal contacts", + "cards" => 10 + ], + ]; + + $response = [ + "status" => "success", + "data" => $addressBooks + ]; + + return $this->json($response, 200); + } +} \ No newline at end of file From fd35bd8b571963a4f9f1b1f1b1138d75ee5233dd Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 22 Jan 2026 17:21:38 +0100 Subject: [PATCH 03/60] Added API endpoints for getting users, user's calendar and sharing calendar to users --- src/Controller/Api/ApiController.php | 199 ++++++++++++++++++++++----- 1 file changed, 162 insertions(+), 37 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 859122b6..66059c78 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -1,10 +1,18 @@ validateApiKey($request)) { return $this->json(['error' => 'Unauthorized'], 401); } - // Dummy data for demonstration purposes - $users = [ - [ - "username" => "johndoe", - "displayname" => "John Doe", - "email" => "johndoe@example.com", - "uri" => "principals/johndoe" - ] - ]; + $principals = $doctrine->getRepository(Principal::class)->findByIsMain(true); + + if (!$principals) { + return $this->json(['status' => 'success', 'data' => []], 200); + } + + foreach ($principals as $principal) { + $users[] = [ + "id" => $principal->getId(), + "uri" => $principal->getUri(), + "username" => $principal->getUsername(), + "displayname" => $principal->getDisplayName(), + "email" => $principal->getEmail(), + ]; + } $response = [ "status" => "success", @@ -63,25 +77,113 @@ public function getUsers(Request $request): JsonResponse return $this->json($response, 200); } + /** + * Retrieves a list of calendars for a specific user. + * + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose calendars are to be retrieved + * @return JsonResponse A JSON response containing the list of calendars + */ #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'])] - public function getUserCalendars(Request $request, string $username): JsonResponse + public function getUserCalendars(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['error' => 'Unauthorized'], 401); + return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); } - // Dummy data for demonstration purposes - $calendars = [ - [ - "displayname" => "Personal Calendar", - "uri" => "default", - "description" => "Work related events", - "events" => 1, - "notes" => 1, - "tasks" => 1 - ], + if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Username'], 400); + } + + $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); + $subscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri(Principal::PREFIX.$username); + + if (!$allCalendars && !$subscriptions) { + return $this->json(['status' => 'success', 'data' => []], 200); + } + + foreach ($allCalendars as $calendar) { + if (!$calendar->isShared()) { + $calendars[] = [ + "displayname" => $calendar->getDisplayName(), + "uri" => $calendar->getUri(), + "id" => $calendar->getId(), + "description" => $calendar->getDescription(), + "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), + "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), + "tasks" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + ]; + } else { + $sharedCalendars[] = [ + "displayname" => $calendar->getDisplayName(), + "uri" => $calendar->getUri(), + "id" => $calendar->getId(), + "description" => $calendar->getDescription(), + "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), + "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), + "tasks" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + ]; + } + } + + foreach ($subscriptions as $subscription) { + $calendars[] = [ + "displayname" => $subscription->getDisplayName(), + "uri" => $subscription->getUri(), + "description" => $subscription->getDescription(), + "events" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), + "notes" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), + "tasks" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + ]; + } + + $response = [ + "status" => "success", + "data" => [ + "user_calendars" => $calendars ?? [], + "other_calendars" => $sharedCalendars ?? [], + "subscriptions" => $subscriptions ?? [] + ] ]; + return $this->json($response, 200); + } + + /** + * Retrieves a list of shares for a specific calendar of a specific user. + * + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose calendar shares are to be retrieved + * @param string $calendar_id The ID of the calendar whose shares are to be retrieved + * @return JsonResponse A JSON response containing the list of calendar shares + */ + #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'])] + public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine) : JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + } + + if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_int($calendar_id)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); + } + + // TODO: Either wrong Id is return or sth else is wrong here + $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); + + if (!$instances) { + return $this->json(['status' => 'success', 'data' => []], 200); + } + + foreach ($instances as $instance) { + $calendars[] = [ + 'username' => mb_substr($instance[0]['principalUri'], strlen(Principal::PREFIX)), + 'displayname' => $instance['displayName'], + 'email' => $instance['email'], + 'write_access' => CalendarInstance::ACCESS_READWRITE === $instance[0]['access'], + ]; + } + $response = [ "status" => "success", "data" => $calendars @@ -90,26 +192,49 @@ public function getUserCalendars(Request $request, string $username): JsonRespon return $this->json($response, 200); } - #[Route('/addressbooks/{username}', name: 'addressbooks', methods: ['GET'])] - public function getUserAddressBooks(Request $request, string $username): JsonResponse + #[Route('/calendars/{username}/share/{calendar_id}', name: 'calendars_share', methods: ['POST'])] + public function setUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['error' => 'Unauthorized'], 401); + return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); } - // Dummy data for demonstration purposes - $addressBooks = [ - [ - "displayname" => "Personal Contacts", - "uri" => "default", - "description" => "My personal contacts", - "cards" => 10 - ], - ]; + if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_numeric($calendar_id)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); + } + + $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); + $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('id')); + + if (!$instance || !$newShareeToAdd) { + return $this->json(['status' => 'Error', 'message' => 'Calendar Instance/User Not Found'], 404); + } + + $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); + + $writeAccess = ('true' === $request->get('write') ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); + + $entityManager = $doctrine->getManager(); + + if ($existingSharedInstance) { + $existingSharedInstance->setAccess($writeAccess); + } else { + $sharedInstance = new CalendarInstance(); + $sharedInstance->setTransparent(1) + ->setCalendar($instance->getCalendar()) + ->setShareHref('mailto:'.$newShareeToAdd->getEmail()) + ->setDescription($instance->getDescription()) + ->setDisplayName($instance->getDisplayName()) + ->setUri(\Sabre\DAV\UUIDUtil::getUUID()) + ->setPrincipalUri($newShareeToAdd->getUri()) + ->setAccess($writeAccess); + $entityManager->persist($sharedInstance); + } + + $entityManager->flush(); $response = [ - "status" => "success", - "data" => $addressBooks + "status" => "success" ]; return $this->json($response, 200); From 6f51d2a4d9973835b71945a70c7cc55298731f84 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 22 Jan 2026 18:05:47 +0100 Subject: [PATCH 04/60] Added API endpoints for getting user details, users calendar details --- src/Controller/Api/ApiController.php | 118 +++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 18 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 66059c78..e5eaf181 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -4,9 +4,8 @@ use App\Entity\Calendar; use App\Entity\CalendarInstance; use App\Entity\CalendarSubscription; - use App\Entity\Principal; -use App\Form\CalendarInstanceType; +use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -77,6 +76,41 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo return $this->json($response, 200); } + /** + * Retrieves details of a specific user. + * + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose details are to be retrieved + * @return JsonResponse A JSON response containing the user details + */ + #[Route('/users/{username}', name: 'user_detail', methods: ['GET'])] + public function getUserDetials(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['error' => 'Unauthorized'], 401); + } + + if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Username'], 400); + } + + $user = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username); + + if (!$user) { + return $this->json(['status' => 'success', 'data' => []], 200); + } + + $response = [ + "id" => $user->getId(), + "uri" => $user->getUri(), + "username" => $user->getUsername(), + "displayname" => $user->getDisplayName(), + "email" => $user->getEmail(), + ]; + + return $this->json($response, 200); + } + /** * Retrieves a list of calendars for a specific user. * @@ -105,9 +139,9 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi foreach ($allCalendars as $calendar) { if (!$calendar->isShared()) { $calendars[] = [ - "displayname" => $calendar->getDisplayName(), - "uri" => $calendar->getUri(), "id" => $calendar->getId(), + "uri" => $calendar->getUri(), + "displayname" => $calendar->getDisplayName(), "description" => $calendar->getDescription(), "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), @@ -115,9 +149,9 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi ]; } else { $sharedCalendars[] = [ - "displayname" => $calendar->getDisplayName(), - "uri" => $calendar->getUri(), "id" => $calendar->getId(), + "uri" => $calendar->getUri(), + "displayname" => $calendar->getDisplayName(), "description" => $calendar->getDescription(), "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), @@ -128,8 +162,9 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi foreach ($subscriptions as $subscription) { $calendars[] = [ - "displayname" => $subscription->getDisplayName(), + "id" => $subscription->getId(), "uri" => $subscription->getUri(), + "displayname" => $subscription->getDisplayName(), "description" => $subscription->getDescription(), "events" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), "notes" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), @@ -141,7 +176,7 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi "status" => "success", "data" => [ "user_calendars" => $calendars ?? [], - "other_calendars" => $sharedCalendars ?? [], + "shared_calendars" => $sharedCalendars ?? [], "subscriptions" => $subscriptions ?? [] ] ]; @@ -149,6 +184,57 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi return $this->json($response, 200); } + /** + * Retrieves details of a specific calendar for a specific user. + * + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose calendar details are to be retrieved + * @param int $calendar_id The ID of the calendar whose details are to be retrieved + * @return JsonResponse A JSON response containing the calendar details + */ + #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'])] + public function getUserCalendarDetails(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + } + + if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Username'], 400); + } + + if (empty($calendar_id) || !is_int($calendar_id)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Calendar ID'], 400); + } + + $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); + + if (!$allCalendars) { + return $this->json(['status' => 'success', 'data' => []], 200); + } + + foreach ($allCalendars as $calendar) { + if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { + $calendar = [ + "id" => $calendar->getId(), + "uri" => $calendar->getUri(), + "displayname" => $calendar->getDisplayName(), + "description" => $calendar->getDescription(), + "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), + "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), + "tasks" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + ]; + } + } + + $response = [ + "status" => "success", + "data" => $calendar ?? [] + ]; + + return $this->json($response, 200); + } + /** * Retrieves a list of shares for a specific calendar of a specific user. * @@ -167,8 +253,7 @@ public function getUserCalendarsShares(Request $request, string $username, int $ if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_int($calendar_id)) { return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); } - - // TODO: Either wrong Id is return or sth else is wrong here + $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); if (!$instances) { @@ -203,6 +288,10 @@ public function setUserCalendarsShare(Request $request, string $username, string return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); } + if (!is_numeric($request->get('id')) || !in_array($request->get('write'), ['true', 'false'], true)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Sharee ID/Write Access Value'], 400); + } + $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('id')); @@ -211,9 +300,7 @@ public function setUserCalendarsShare(Request $request, string $username, string } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); - $writeAccess = ('true' === $request->get('write') ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); - $entityManager = $doctrine->getManager(); if ($existingSharedInstance) { @@ -230,13 +317,8 @@ public function setUserCalendarsShare(Request $request, string $username, string ->setAccess($writeAccess); $entityManager->persist($sharedInstance); } - $entityManager->flush(); - $response = [ - "status" => "success" - ]; - - return $this->json($response, 200); + return $this->json(["status" => "success"], 200); } } \ No newline at end of file From 5a7bf82105d19a456585bb2bcebd021f1f9aadfa Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 22 Jan 2026 18:06:35 +0100 Subject: [PATCH 05/60] Code Linting --- src/Command/ApiGenerateCommand.php | 2 +- src/Controller/Api/ApiController.php | 146 ++++++++++++++------------- 2 files changed, 77 insertions(+), 71 deletions(-) diff --git a/src/Command/ApiGenerateCommand.php b/src/Command/ApiGenerateCommand.php index fa00cf47..5d4cdf27 100644 --- a/src/Command/ApiGenerateCommand.php +++ b/src/Command/ApiGenerateCommand.php @@ -39,4 +39,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index e5eaf181..8b20407c 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -1,4 +1,5 @@ headers->get('X-API-Key'); + return hash_equals($this->apiKey, $key ?? ''); } @@ -38,12 +39,13 @@ public function healthCheck(Request $request): JsonResponse return $this->json(['status' => 'OK'], 200); } - + /** * Retrieves a list of users. * * @param Request $request The HTTP GET request - * @return JsonResponse A JSON response containing the list of users, + * + * @return JsonResponse A JSON response containing the list of users, */ #[Route('/users', name: 'users', methods: ['GET'])] public function getUsers(Request $request, ManagerRegistry $doctrine): JsonResponse @@ -60,17 +62,17 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo foreach ($principals as $principal) { $users[] = [ - "id" => $principal->getId(), - "uri" => $principal->getUri(), - "username" => $principal->getUsername(), - "displayname" => $principal->getDisplayName(), - "email" => $principal->getEmail(), + 'id' => $principal->getId(), + 'uri' => $principal->getUri(), + 'username' => $principal->getUsername(), + 'displayname' => $principal->getDisplayName(), + 'email' => $principal->getEmail(), ]; } $response = [ - "status" => "success", - "data" => $users + 'status' => 'success', + 'data' => $users, ]; return $this->json($response, 200); @@ -79,12 +81,13 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo /** * Retrieves details of a specific user. * - * @param Request $request The HTTP GET request - * @param string $username The username of the user whose details are to be retrieved + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose details are to be retrieved + * * @return JsonResponse A JSON response containing the user details */ #[Route('/users/{username}', name: 'user_detail', methods: ['GET'])] - public function getUserDetials(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse + public function getUserDetials(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['error' => 'Unauthorized'], 401); @@ -101,11 +104,11 @@ public function getUserDetials(Request $request, ManagerRegistry $doctrine, stri } $response = [ - "id" => $user->getId(), - "uri" => $user->getUri(), - "username" => $user->getUsername(), - "displayname" => $user->getDisplayName(), - "email" => $user->getEmail(), + 'id' => $user->getId(), + 'uri' => $user->getUri(), + 'username' => $user->getUsername(), + 'displayname' => $user->getDisplayName(), + 'email' => $user->getEmail(), ]; return $this->json($response, 200); @@ -114,8 +117,9 @@ public function getUserDetials(Request $request, ManagerRegistry $doctrine, stri /** * Retrieves a list of calendars for a specific user. * - * @param Request $request The HTTP GET request - * @param string $username The username of the user whose calendars are to be retrieved + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose calendars are to be retrieved + * * @return JsonResponse A JSON response containing the list of calendars */ #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'])] @@ -139,46 +143,46 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi foreach ($allCalendars as $calendar) { if (!$calendar->isShared()) { $calendars[] = [ - "id" => $calendar->getId(), - "uri" => $calendar->getUri(), - "displayname" => $calendar->getDisplayName(), - "description" => $calendar->getDescription(), - "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), - "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), - "tasks" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + 'id' => $calendar->getId(), + 'uri' => $calendar->getUri(), + 'displayname' => $calendar->getDisplayName(), + 'description' => $calendar->getDescription(), + 'events' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), + 'notes' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), + 'tasks' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), ]; } else { $sharedCalendars[] = [ - "id" => $calendar->getId(), - "uri" => $calendar->getUri(), - "displayname" => $calendar->getDisplayName(), - "description" => $calendar->getDescription(), - "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), - "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), - "tasks" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + 'id' => $calendar->getId(), + 'uri' => $calendar->getUri(), + 'displayname' => $calendar->getDisplayName(), + 'description' => $calendar->getDescription(), + 'events' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), + 'notes' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), + 'tasks' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), ]; } } foreach ($subscriptions as $subscription) { $calendars[] = [ - "id" => $subscription->getId(), - "uri" => $subscription->getUri(), - "displayname" => $subscription->getDisplayName(), - "description" => $subscription->getDescription(), - "events" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), - "notes" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), - "tasks" => count($subscription->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + 'id' => $subscription->getId(), + 'uri' => $subscription->getUri(), + 'displayname' => $subscription->getDisplayName(), + 'description' => $subscription->getDescription(), + 'events' => count($subscription->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), + 'notes' => count($subscription->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), + 'tasks' => count($subscription->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), ]; } $response = [ - "status" => "success", - "data" => [ - "user_calendars" => $calendars ?? [], - "shared_calendars" => $sharedCalendars ?? [], - "subscriptions" => $subscriptions ?? [] - ] + 'status' => 'success', + 'data' => [ + 'user_calendars' => $calendars ?? [], + 'shared_calendars' => $sharedCalendars ?? [], + 'subscriptions' => $subscriptions ?? [], + ], ]; return $this->json($response, 200); @@ -187,9 +191,10 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi /** * Retrieves details of a specific calendar for a specific user. * - * @param Request $request The HTTP GET request - * @param string $username The username of the user whose calendar details are to be retrieved - * @param int $calendar_id The ID of the calendar whose details are to be retrieved + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose calendar details are to be retrieved + * @param int $calendar_id The ID of the calendar whose details are to be retrieved + * * @return JsonResponse A JSON response containing the calendar details */ #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'])] @@ -216,20 +221,20 @@ public function getUserCalendarDetails(Request $request, string $username, int $ foreach ($allCalendars as $calendar) { if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { $calendar = [ - "id" => $calendar->getId(), - "uri" => $calendar->getUri(), - "displayname" => $calendar->getDisplayName(), - "description" => $calendar->getDescription(), - "events" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_EVENTS)), - "notes" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_NOTES)), - "tasks" => count($calendar->getCalendar()->getObjects()->filter(fn($obj) => $obj->getComponentType() === Calendar::COMPONENT_TODOS)), + 'id' => $calendar->getId(), + 'uri' => $calendar->getUri(), + 'displayname' => $calendar->getDisplayName(), + 'description' => $calendar->getDescription(), + 'events' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), + 'notes' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), + 'tasks' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), ]; } } $response = [ - "status" => "success", - "data" => $calendar ?? [] + 'status' => 'success', + 'data' => $calendar ?? [], ]; return $this->json($response, 200); @@ -238,13 +243,14 @@ public function getUserCalendarDetails(Request $request, string $username, int $ /** * Retrieves a list of shares for a specific calendar of a specific user. * - * @param Request $request The HTTP GET request - * @param string $username The username of the user whose calendar shares are to be retrieved - * @param string $calendar_id The ID of the calendar whose shares are to be retrieved + * @param Request $request The HTTP GET request + * @param string $username The username of the user whose calendar shares are to be retrieved + * @param string $calendar_id The ID of the calendar whose shares are to be retrieved + * * @return JsonResponse A JSON response containing the list of calendar shares */ #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'])] - public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine) : JsonResponse + public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); @@ -253,13 +259,13 @@ public function getUserCalendarsShares(Request $request, string $username, int $ if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_int($calendar_id)) { return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); } - + $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); if (!$instances) { return $this->json(['status' => 'success', 'data' => []], 200); } - + foreach ($instances as $instance) { $calendars[] = [ 'username' => mb_substr($instance[0]['principalUri'], strlen(Principal::PREFIX)), @@ -270,8 +276,8 @@ public function getUserCalendarsShares(Request $request, string $username, int $ } $response = [ - "status" => "success", - "data" => $calendars + 'status' => 'success', + 'data' => $calendars, ]; return $this->json($response, 200); @@ -319,6 +325,6 @@ public function setUserCalendarsShare(Request $request, string $username, string } $entityManager->flush(); - return $this->json(["status" => "success"], 200); + return $this->json(['status' => 'success'], 200); } -} \ No newline at end of file +} From e0ffc325a6c72f1aff8a434e7d75ef543a7136d4 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 22 Jan 2026 21:55:48 +0100 Subject: [PATCH 06/60] Added API endpoints for adding/revoking shared calendar --- src/Controller/Api/ApiController.php | 55 +++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 8b20407c..3a8dde37 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -6,7 +6,6 @@ use App\Entity\CalendarInstance; use App\Entity\CalendarSubscription; use App\Entity\Principal; -use App\Entity\User; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -283,7 +282,16 @@ public function getUserCalendarsShares(Request $request, string $username, int $ return $this->json($response, 200); } - #[Route('/calendars/{username}/share/{calendar_id}', name: 'calendars_share', methods: ['POST'])] + /** + * Sets or updates a share for a specific calendar of a specific user. + * + * @param Request $request The HTTP POST request + * @param string $username The username of the user whose calendar share is to be set or updated + * @param string $calendar_id The ID of the calendar whose share is to be set or updated + * + * @return JsonResponse A JSON response indicating the success or failure of the operation + */ + #[Route('/calendars/{username}/shares/{calendar_id}/add', name: 'calendars_share', methods: ['POST'])] public function setUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -325,6 +333,49 @@ public function setUserCalendarsShare(Request $request, string $username, string } $entityManager->flush(); + return $this->json(['status' => 'success'], 200); + } + + /** + * Removes a share for a specific calendar of a specific user. + * + * @param Request $request The HTTP POST request + * @param string $username The username of the user whose calendar share is to be removed + * @param string $calendar_id The ID of the calendar whose share is to be removed + * + * @return JsonResponse A JSON response indicating the success or failure of the operation + */ + #[Route('/calendars/{username}/shares/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'])] + public function removeUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse + { + if (!$this->validateApiKey($request)) { + return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + } + + if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_numeric($calendar_id)) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); + } + + if (!is_numeric($request->get('id'))) { + return $this->json(['status' => 'Error', 'message' => 'Invalid Sharee ID'], 400); + } + + $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); + $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($request->get('id')); + + if (!$instance || !$shareeToRemove) { + return $this->json(['status' => 'Error', 'message' => 'Calendar Instance/User Not Found'], 404); + } + + $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $shareeToRemove->getUri()); + + if ($existingSharedInstance) { + $entityManager = $doctrine->getManager(); + $entityManager->remove($existingSharedInstance); + $entityManager->flush(); + } + + return $this->json(['status' => 'success'], 200); } } From 0b0a66fa4bc217779cb8801c537a11023e587a4a Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 22 Jan 2026 21:57:36 +0100 Subject: [PATCH 07/60] Code Linting --- src/Controller/Api/ApiController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 3a8dde37..dc15d54d 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -375,7 +375,6 @@ public function removeUserCalendarsShare(Request $request, string $username, str $entityManager->flush(); } - return $this->json(['status' => 'success'], 200); } } From 9116764d081f844dcc24d9c3595f9d567364a0b4 Mon Sep 17 00:00:00 2001 From: Jakub Date: Fri, 23 Jan 2026 14:01:15 +0100 Subject: [PATCH 08/60] Changes to share API endpoint path, added information to output --- src/Controller/Api/ApiController.php | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index dc15d54d..92458636 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -86,7 +86,7 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo * @return JsonResponse A JSON response containing the user details */ #[Route('/users/{username}', name: 'user_detail', methods: ['GET'])] - public function getUserDetials(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse + public function getUserDetails(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['error' => 'Unauthorized'], 401); @@ -266,8 +266,11 @@ public function getUserCalendarsShares(Request $request, string $username, int $ } foreach ($instances as $instance) { + $user_id = $doctrine->getRepository(Principal::class)->findOneByUri($instance[0]['principalUri']); + $calendars[] = [ 'username' => mb_substr($instance[0]['principalUri'], strlen(Principal::PREFIX)), + 'user_id' => $user_id->getId() ?? null, 'displayname' => $instance['displayName'], 'email' => $instance['email'], 'write_access' => CalendarInstance::ACCESS_READWRITE === $instance[0]['access'], @@ -276,7 +279,7 @@ public function getUserCalendarsShares(Request $request, string $username, int $ $response = [ 'status' => 'success', - 'data' => $calendars, + 'data' => $calendars ?? [], ]; return $this->json($response, 200); @@ -291,7 +294,7 @@ public function getUserCalendarsShares(Request $request, string $username, int $ * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/shares/{calendar_id}/add', name: 'calendars_share', methods: ['POST'])] + #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'])] public function setUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -302,19 +305,19 @@ public function setUserCalendarsShare(Request $request, string $username, string return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); } - if (!is_numeric($request->get('id')) || !in_array($request->get('write'), ['true', 'false'], true)) { + if (!is_numeric($request->get('user_id')) || !in_array($request->get('write_access'), ['true', 'false'], true)) { return $this->json(['status' => 'Error', 'message' => 'Invalid Sharee ID/Write Access Value'], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); - $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('id')); + $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('user_id')); if (!$instance || !$newShareeToAdd) { return $this->json(['status' => 'Error', 'message' => 'Calendar Instance/User Not Found'], 404); } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); - $writeAccess = ('true' === $request->get('write') ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); + $writeAccess = ('true' === $request->get('write_access') ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); $entityManager = $doctrine->getManager(); if ($existingSharedInstance) { @@ -345,7 +348,7 @@ public function setUserCalendarsShare(Request $request, string $username, string * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/shares/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'])] + #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'])] public function removeUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -356,12 +359,12 @@ public function removeUserCalendarsShare(Request $request, string $username, str return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); } - if (!is_numeric($request->get('id'))) { + if (!is_numeric($request->get('user_id'))) { return $this->json(['status' => 'Error', 'message' => 'Invalid Sharee ID'], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); - $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($request->get('id')); + $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($request->get('user_id')); if (!$instance || !$shareeToRemove) { return $this->json(['status' => 'Error', 'message' => 'Calendar Instance/User Not Found'], 404); From 9d547d5773893b11db4a69c674b3eabc513fa53a Mon Sep 17 00:00:00 2001 From: Jakub Date: Fri, 23 Jan 2026 16:20:58 +0100 Subject: [PATCH 09/60] Changes to API json returns --- src/Controller/Api/ApiController.php | 49 ++++++++++++++-------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 92458636..c5413fa5 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -32,11 +32,7 @@ private function validateApiKey(Request $request): bool #[Route('/health', name: 'health', methods: ['GET'])] public function healthCheck(Request $request): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['error' => 'Unauthorized'], 401); - } - - return $this->json(['status' => 'OK'], 200); + return $this->json(['status' => 'OK', 'timestamp' => date('c')], 200); } /** @@ -50,7 +46,7 @@ public function healthCheck(Request $request): JsonResponse public function getUsers(Request $request, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['error' => 'Unauthorized'], 401); + return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } $principals = $doctrine->getRepository(Principal::class)->findByIsMain(true); @@ -89,11 +85,11 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo public function getUserDetails(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['error' => 'Unauthorized'], 401); + return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Username'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); } $user = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username); @@ -102,7 +98,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri return $this->json(['status' => 'success', 'data' => []], 200); } - $response = [ + $data = [ 'id' => $user->getId(), 'uri' => $user->getUri(), 'username' => $user->getUsername(), @@ -110,6 +106,11 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri 'email' => $user->getEmail(), ]; + $response = [ + 'status' => 'success', + 'data' => $data, + ]; + return $this->json($response, 200); } @@ -125,11 +126,11 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri public function getUserCalendars(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Username'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); } $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); @@ -200,15 +201,15 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi public function getUserCalendarDetails(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Username'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); } if (empty($calendar_id) || !is_int($calendar_id)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Calendar ID'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID'], 400); } $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); @@ -252,11 +253,11 @@ public function getUserCalendarDetails(Request $request, string $username, int $ public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_int($calendar_id)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); } $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); @@ -298,22 +299,22 @@ public function getUserCalendarsShares(Request $request, string $username, int $ public function setUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_numeric($calendar_id)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); } if (!is_numeric($request->get('user_id')) || !in_array($request->get('write_access'), ['true', 'false'], true)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Sharee ID/Write Access Value'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID/Write Access Value'], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('user_id')); if (!$instance || !$newShareeToAdd) { - return $this->json(['status' => 'Error', 'message' => 'Calendar Instance/User Not Found'], 404); + return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found'], 404); } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); @@ -352,22 +353,22 @@ public function setUserCalendarsShare(Request $request, string $username, string public function removeUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'Error', 'message' => 'Unauthorized'], 401); + return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_numeric($calendar_id)) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Username/Calendar ID'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); } if (!is_numeric($request->get('user_id'))) { - return $this->json(['status' => 'Error', 'message' => 'Invalid Sharee ID'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID'], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($request->get('user_id')); if (!$instance || !$shareeToRemove) { - return $this->json(['status' => 'Error', 'message' => 'Calendar Instance/User Not Found'], 404); + return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found'], 404); } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $shareeToRemove->getUri()); From 27869077aeffd61aa22702c8e0fe2c9b6ce21175 Mon Sep 17 00:00:00 2001 From: Jakub Date: Fri, 23 Jan 2026 16:22:22 +0100 Subject: [PATCH 10/60] API Docs: Part 1 --- docs/api/README.md | 28 ++++++++++ docs/api/calendars/all.md | 106 ++++++++++++++++++++++++++++++++++++++ docs/api/health.md | 22 ++++++++ docs/api/users/all.md | 53 +++++++++++++++++++ docs/api/users/details.md | 75 +++++++++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 docs/api/README.md create mode 100644 docs/api/calendars/all.md create mode 100644 docs/api/health.md create mode 100644 docs/api/users/all.md create mode 100644 docs/api/users/details.md diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 00000000..44197bcf --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,28 @@ +# Davis API + +## Open Endpoints + +Open endpoints require no Authentication. + +* [Health](health.md) : `GET /api/health` + +## Endpoints that require Authentication + +Closed endpoints require a valid `X-API-Key` to be included in the header of the request. Token needs to be configured in .env file and can be generated using `php bin/console api:generate` command. + +### User related + +Each endpoint displays information related to the User: + +* [Get Users](users/all.md) : `GET /api/users` +* [Get User Details](users/details.md) : `GET /api/users/:username` + +### Calendars related + +Endpoints for viewing and modifying user calendars. + +* [Show All User Calendars](calendars/all.md) : `GET /api/calendars/:username` +* Show User Calendar Details : `GET /api/calendars/:username/:calendar_id` +* Show User Calendar Shares : `GET /api/calendars/:username/shares/:calendar_id` +* Share User Calendar : `POST /api/calendars/:username/share/:calendar_id/add` +* Remove Share User Calendar : `POST /api/calendars/:username/share/:calendar_id/remove` diff --git a/docs/api/calendars/all.md b/docs/api/calendars/all.md new file mode 100644 index 00000000..3baf2803 --- /dev/null +++ b/docs/api/calendars/all.md @@ -0,0 +1,106 @@ +# User Calendars + +Gets a list of all available calendars for a specific user. + +**URL** : `/api/calendars/:username` + +**Method** : `GET` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +``` + +**URL example** + +```json +/api/calendars/jdoe +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": { + "user_calendars": [ + { + "id": 1, + "uri": "default", + "displayname": "Default Calendar", + "description": "Default Calendar for John Doe", + "events": 0, + "notes": 0, + "tasks": 0 + } + ], + "shared_calendars": [ + { + "id": 10, + "uri": "c2152eb0-ada1-451f-bf33-b4a9571ec92e", + "displayname": "Default Calendar", + "description": "Default Calendar for Mark Doe", + "events": 0, + "notes": 0, + "tasks": 0 + } + ], + "subscriptions": [] + } +} +``` + +Shown when no there are no users in Davis: +```json +{ + "status": "success", + "data": [] +} +``` + +Shown when user does not have calendars: +```json +{ + "status": "success", + "data": { + "user_calendars": [], + "shared_calendars": [], + "subscriptions": [] + } +} +``` + +## Error Response + +**Condition** : If 'X-API-Key' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "status": "error", + "message": "Unauthorized" +} +``` + +**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Username" +} +``` \ No newline at end of file diff --git a/docs/api/health.md b/docs/api/health.md new file mode 100644 index 00000000..cd765e55 --- /dev/null +++ b/docs/api/health.md @@ -0,0 +1,22 @@ +# Health + +Used to check if the API endpoint is active. + +**URL** : `/api/health/` + +**Method** : `GET` + +**Auth required** : NO + +## Success Response + +**Code** : `200 OK` + +**Content example** + +```json +{ + "status": "OK", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` diff --git a/docs/api/users/all.md b/docs/api/users/all.md new file mode 100644 index 00000000..e9c0ce48 --- /dev/null +++ b/docs/api/users/all.md @@ -0,0 +1,53 @@ +# Get Users + +Gets a list of all available Davis users. + +**URL** : `/api/users` + +**Method** : `GET` + +**Auth required** : YES + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": [ + { + "id": 3, + "uri": "principals/jdoe", + "username": "jdoe", + "displayname": "John Doe", + "email": "jdoe@example.org" + } + ] +} +``` + +Shown when no there are no users in Davis: +```json +{ + "status": "success", + "data": [] +} +``` + +## Error Response + +**Condition** : If 'X-API-Key' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "status": "error", + "message": "Unauthorized" +} +``` diff --git a/docs/api/users/details.md b/docs/api/users/details.md new file mode 100644 index 00000000..d9b752b6 --- /dev/null +++ b/docs/api/users/details.md @@ -0,0 +1,75 @@ +# User Details + +Used for getting details regarding a specific user. + +**URL** : `/api/users/:username` + +**Method** : `GET` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +``` + +**URL example** + +```json +/api/users/jdoe +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": { + "id": 3, + "uri": "principals/jdoe", + "username": "jdoe", + "displayname": "John Doe", + "email": "jdoe@example.org" + } +} +``` + +Shown when there are no users in Davis: +```json +{ + "status": "success", + "data": [] +} +``` + +## Error Response + +**Condition** : If 'X-API-Key' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "status": "error", + "message": "Unauthorized" +} +``` + +**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Username" +} \ No newline at end of file From c78554bd343e676f73dd2e0739765c352102194a Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 25 Jan 2026 20:27:49 +0100 Subject: [PATCH 11/60] Fixes to API endpoint params and permissions checks --- src/Controller/Api/ApiController.php | 78 ++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index c5413fa5..a295eee7 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -67,7 +67,7 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo $response = [ 'status' => 'success', - 'data' => $users, + 'data' => $users ?? [], ]; return $this->json($response, 200); @@ -108,7 +108,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri $response = [ 'status' => 'success', - 'data' => $data, + 'data' => $data ?? [], ]; return $this->json($response, 200); @@ -197,7 +197,7 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi * * @return JsonResponse A JSON response containing the calendar details */ - #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'])] + #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => "\d+"])] public function getUserCalendarDetails(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -220,7 +220,7 @@ public function getUserCalendarDetails(Request $request, string $username, int $ foreach ($allCalendars as $calendar) { if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { - $calendar = [ + $calendar_details = [ 'id' => $calendar->getId(), 'uri' => $calendar->getUri(), 'displayname' => $calendar->getDisplayName(), @@ -234,7 +234,7 @@ public function getUserCalendarDetails(Request $request, string $username, int $ $response = [ 'status' => 'success', - 'data' => $calendar ?? [], + 'data' => $calendar_details ?? [], ]; return $this->json($response, 200); @@ -249,15 +249,28 @@ public function getUserCalendarDetails(Request $request, string $username, int $ * * @return JsonResponse A JSON response containing the list of calendar shares */ - #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'])] + #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => "\d+"])] public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_int($calendar_id)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); + if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); + } + + if (!is_int($calendar_id)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID'], 400); + } + + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ + 'id' => $calendar_id, + 'principalUri' => Principal::PREFIX.$username, + ]); + + if (!$ownerInstance) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username'], 400); } $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); @@ -271,7 +284,7 @@ public function getUserCalendarsShares(Request $request, string $username, int $ $calendars[] = [ 'username' => mb_substr($instance[0]['principalUri'], strlen(Principal::PREFIX)), - 'user_id' => $user_id->getId() ?? null, + 'user_id' => $user_id?->getId() ?? null, 'displayname' => $instance['displayName'], 'email' => $instance['email'], 'write_access' => CalendarInstance::ACCESS_READWRITE === $instance[0]['access'], @@ -295,34 +308,45 @@ public function getUserCalendarsShares(Request $request, string $username, int $ * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'])] - public function setUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse + #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => "\d+"])] + public function setUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_numeric($calendar_id)) { + if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); } - if (!is_numeric($request->get('user_id')) || !in_array($request->get('write_access'), ['true', 'false'], true)) { + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ + 'id' => $calendar_id, + 'principalUri' => Principal::PREFIX.$username, + ]); + + if (!$ownerInstance) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username'], 400); + } + + $userId = $request->get('user_id'); + $writeAccess = $request->get('write_access'); + if (!is_numeric($userId) || !in_array($writeAccess, ['true', 'false'], true)) { return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID/Write Access Value'], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); - $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('user_id')); + $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($userId); if (!$instance || !$newShareeToAdd) { return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found'], 404); } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); - $writeAccess = ('true' === $request->get('write_access') ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); + $accessLevel = ('true' === $writeAccess ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); $entityManager = $doctrine->getManager(); if ($existingSharedInstance) { - $existingSharedInstance->setAccess($writeAccess); + $existingSharedInstance->setAccess($accessLevel); } else { $sharedInstance = new CalendarInstance(); $sharedInstance->setTransparent(1) @@ -332,7 +356,7 @@ public function setUserCalendarsShare(Request $request, string $username, string ->setDisplayName($instance->getDisplayName()) ->setUri(\Sabre\DAV\UUIDUtil::getUUID()) ->setPrincipalUri($newShareeToAdd->getUri()) - ->setAccess($writeAccess); + ->setAccess($accessLevel); $entityManager->persist($sharedInstance); } $entityManager->flush(); @@ -349,23 +373,33 @@ public function setUserCalendarsShare(Request $request, string $username, string * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'])] - public function removeUserCalendarsShare(Request $request, string $username, string $calendar_id, ManagerRegistry $doctrine): JsonResponse + #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => "\d+"])] + public function removeUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (!is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username) || !is_numeric($calendar_id)) { + if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); } - if (!is_numeric($request->get('user_id'))) { + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ + 'id' => $calendar_id, + 'principalUri' => Principal::PREFIX.$username, + ]); + + if (!$ownerInstance) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username'], 400); + } + + $userId = $request->get('user_id'); + if (!is_numeric($userId)) { return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID'], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); - $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($request->get('user_id')); + $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($userId); if (!$instance || !$shareeToRemove) { return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found'], 404); From f594513338eb30a7226ae484a6ae14c2fbca7fbd Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 25 Jan 2026 20:28:09 +0100 Subject: [PATCH 12/60] API Docs: Part 2 --- docs/api/README.md | 8 +-- docs/api/calendars/all.md | 2 +- docs/api/calendars/details.md | 92 ++++++++++++++++++++++++ docs/api/calendars/share_add.md | 88 +++++++++++++++++++++++ docs/api/calendars/share_remove.md | 88 +++++++++++++++++++++++ docs/api/calendars/shares.md | 112 +++++++++++++++++++++++++++++ docs/api/users/all.md | 2 +- docs/api/users/details.md | 2 +- 8 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 docs/api/calendars/details.md create mode 100644 docs/api/calendars/share_add.md create mode 100644 docs/api/calendars/share_remove.md create mode 100644 docs/api/calendars/shares.md diff --git a/docs/api/README.md b/docs/api/README.md index 44197bcf..47fe40ad 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -22,7 +22,7 @@ Each endpoint displays information related to the User: Endpoints for viewing and modifying user calendars. * [Show All User Calendars](calendars/all.md) : `GET /api/calendars/:username` -* Show User Calendar Details : `GET /api/calendars/:username/:calendar_id` -* Show User Calendar Shares : `GET /api/calendars/:username/shares/:calendar_id` -* Share User Calendar : `POST /api/calendars/:username/share/:calendar_id/add` -* Remove Share User Calendar : `POST /api/calendars/:username/share/:calendar_id/remove` +* [Show User Calendar Details](calendars/details.md) : `GET /api/calendars/:username/:calendar_id` +* [Show User Calendar Shares](calendars/shares.md) : `GET /api/calendars/:username/shares/:calendar_id` +* [Share User Calendar](calendars/share_add.md) : `POST /api/calendars/:username/share/:calendar_id/add` +* [Remove Share User Calendar](calendars/share_remove.md) : `POST /api/calendars/:username/share/:calendar_id/remove` \ No newline at end of file diff --git a/docs/api/calendars/all.md b/docs/api/calendars/all.md index 3baf2803..4d9ced5f 100644 --- a/docs/api/calendars/all.md +++ b/docs/api/calendars/all.md @@ -57,7 +57,7 @@ Gets a list of all available calendars for a specific user. } ``` -Shown when no there are no users in Davis: +Shown when there are no users in Davis: ```json { "status": "success", diff --git a/docs/api/calendars/details.md b/docs/api/calendars/details.md new file mode 100644 index 00000000..1c1193fa --- /dev/null +++ b/docs/api/calendars/details.md @@ -0,0 +1,92 @@ +# User Calendar Details + +Gets a list of all available calendars for a specific user. + +**URL** : `/api/calendars/:username/:calendar_id` + +**Method** : `GET` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +:calendar_id -> "[numeric id of a calendar owned by the user]", +``` + +**URL example** + +```json +/api/calendars/jdoe/1 +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": { + "id": 1, + "uri": "default", + "displayname": "Default Calendar", + "description": "Default Calendar for Joe Doe", + "events": 0, + "notes": 0, + "tasks": 0 + } +} +``` + +Shown when user has no calendars with the given id: +```json +{ + "status": "success", + "data": [] +} +``` + +## Error Response + +**Condition** : If 'X-API-Key' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "status": "error", + "message": "Unauthorized" +} +``` + +**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Username" +} +``` + +**Condition** : If ':calendar_id' is not a valid numeric value. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID" +} +``` \ No newline at end of file diff --git a/docs/api/calendars/share_add.md b/docs/api/calendars/share_add.md new file mode 100644 index 00000000..ff60d3d0 --- /dev/null +++ b/docs/api/calendars/share_add.md @@ -0,0 +1,88 @@ +# Share User Calendar + +Shares (or updates write access) a calendar owned by the specified user to another user. + +**URL** : `/api/calendars/:username/share/:calendar_id/add` + +**Method** : `POST` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +:calendar_id -> "[numeric id of a calendar owned by the user]", +``` + +**URL example** + +```json +/api/calendars/mdoe/share/1/add +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success" +} +``` + +## Error Response + +**Condition** : If 'X-API-Key' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "status": "error", + "message": "Unauthorized" +} +``` + +**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Username" +} +``` + +**Condition** : If ':calendar_id' is not a valid numeric value. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID" +} +``` + +**Condition** : If ':calendar_id' is not for the specified ':username'. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID/Username" +} +``` \ No newline at end of file diff --git a/docs/api/calendars/share_remove.md b/docs/api/calendars/share_remove.md new file mode 100644 index 00000000..182a00bc --- /dev/null +++ b/docs/api/calendars/share_remove.md @@ -0,0 +1,88 @@ +# Remove Share User Calendar + +Removes access to a specific shared calendar for a specific user. + +**URL** : `/api/calendars/:username/share/:calendar_id/remove` + +**Method** : `POST` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +:calendar_id -> "[numeric id of a calendar owned by the user]", +``` + +**URL example** + +```json +/api/calendars/mdoe/share/1/remove +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success" +} +``` + +## Error Response + +**Condition** : If 'X-API-Key' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "status": "error", + "message": "Unauthorized" +} +``` + +**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Username" +} +``` + +**Condition** : If ':calendar_id' is not a valid numeric value. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID" +} +``` + +**Condition** : If ':calendar_id' is not for the specified ':username'. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID/Username" +} +``` \ No newline at end of file diff --git a/docs/api/calendars/shares.md b/docs/api/calendars/shares.md new file mode 100644 index 00000000..c9f45122 --- /dev/null +++ b/docs/api/calendars/shares.md @@ -0,0 +1,112 @@ +# User Calendar Shares + +Gets a list of all users with whom a specific user calendar is shared. + +**URL** : `/api/calendars/:username/shares/:calendar_id` + +**Method** : `GET` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +:calendar_id -> "[numeric id of a calendar owned by the user]", +``` + +**URL example** + +```json +/api/calendars/mdoe/shares/1 +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": [ + { + "username": "adoe", + "user_id": 9, + "displayname": "Aiden Doe", + "email": "adoe@example.org", + "write_access": false + }, + { + "username": "jdoe", + "user_id": 3, + "displayname": "John Doe", + "email": "jdoe@example.org", + "write_access": true + } + ] +} +``` + +Shown when user has no calendars with the given id: +```json +{ + "status": "success", + "data": [] +} +``` + +## Error Response + +**Condition** : If 'X-API-Key' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "status": "error", + "message": "Unauthorized" +} +``` + +**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Username" +} +``` + +**Condition** : If ':calendar_id' is not a valid numeric value. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID" +} +``` + +**Condition** : If ':calendar_id' is not for the specified ':username'. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID/Username" +} +``` \ No newline at end of file diff --git a/docs/api/users/all.md b/docs/api/users/all.md index e9c0ce48..6ae2ad8b 100644 --- a/docs/api/users/all.md +++ b/docs/api/users/all.md @@ -29,7 +29,7 @@ Gets a list of all available Davis users. } ``` -Shown when no there are no users in Davis: +Shown when there are no users in Davis: ```json { "status": "success", diff --git a/docs/api/users/details.md b/docs/api/users/details.md index d9b752b6..7a7f7ee8 100644 --- a/docs/api/users/details.md +++ b/docs/api/users/details.md @@ -1,6 +1,6 @@ # User Details -Used for getting details regarding a specific user. +Gets details about a specific user account. **URL** : `/api/users/:username` From d626b6924ed061f6c82da0ed3af7ebd30728b8b5 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 25 Jan 2026 20:36:04 +0100 Subject: [PATCH 13/60] Updated main README.md file to include information and link to API endpoint documentation --- README.md | 10 ++++++++++ docs/api/README.md | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f78d94a0..4563e3b2 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,16 @@ The main endpoint for CalDAV, WebDAV or CardDAV is at `/dav`. > > For shared hosting, the `symfony/apache-pack` is included and provides a standard `.htaccess` file in the public directory so redirections should work out of the box. +## API Endpoint + +For user and calendar management there is an API endpoint. See [the API documentation](docs/api/README.md) for more information. + +> [!TIP] +> +> The API endpoint requires an environment variable `API_KEY` set to a secret key that you will use in the `X-API-Key` header of your requests to authenticate. You can generate it with `bin/console api:generate` + +## Webserver Configuration Examples + ### Example Caddy 2 configuration ``` diff --git a/docs/api/README.md b/docs/api/README.md index 47fe40ad..427dcc90 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -8,7 +8,7 @@ Open endpoints require no Authentication. ## Endpoints that require Authentication -Closed endpoints require a valid `X-API-Key` to be included in the header of the request. Token needs to be configured in .env file and can be generated using `php bin/console api:generate` command. +Closed endpoints require a valid `X-API-Key` to be included in the header of the request. Token needs to be configured in .env file (as a environment variable `API_KEY`) and can be generated using `php bin/console api:generate` command. ### User related From 154452fcb0f293cb6630bf27e098911a7222d7fe Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 25 Jan 2026 20:42:33 +0100 Subject: [PATCH 14/60] Updated code comments and adjusted user list API endpoint --- docs/api/users/all.md | 4 +--- src/Controller/Api/ApiController.php | 23 ++++++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/api/users/all.md b/docs/api/users/all.md index 6ae2ad8b..997e62ab 100644 --- a/docs/api/users/all.md +++ b/docs/api/users/all.md @@ -1,6 +1,6 @@ # Get Users -Gets a list of all available Davis users. +Gets a list of all available users. **URL** : `/api/users` @@ -22,8 +22,6 @@ Gets a list of all available Davis users. "id": 3, "uri": "principals/jdoe", "username": "jdoe", - "displayname": "John Doe", - "email": "jdoe@example.org" } ] } diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index a295eee7..3c6615d8 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -29,6 +29,13 @@ private function validateApiKey(Request $request): bool return hash_equals($this->apiKey, $key ?? ''); } + /** + * Health check endpoint. + * + * @param Request $request The HTTP GET request + * + * @return JsonResponse A JSON response indicating the health status + */ #[Route('/health', name: 'health', methods: ['GET'])] public function healthCheck(Request $request): JsonResponse { @@ -36,11 +43,11 @@ public function healthCheck(Request $request): JsonResponse } /** - * Retrieves a list of users. + * Retrieves a list of users (with their id, uri, username, and displayname). * * @param Request $request The HTTP GET request * - * @return JsonResponse A JSON response containing the list of users, + * @return JsonResponse A JSON response containing the list of users */ #[Route('/users', name: 'users', methods: ['GET'])] public function getUsers(Request $request, ManagerRegistry $doctrine): JsonResponse @@ -60,8 +67,6 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo 'id' => $principal->getId(), 'uri' => $principal->getUri(), 'username' => $principal->getUsername(), - 'displayname' => $principal->getDisplayName(), - 'email' => $principal->getEmail(), ]; } @@ -74,7 +79,7 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo } /** - * Retrieves details of a specific user. + * Retrieves details of a specific user (id, uri, username, displayname, email). * * @param Request $request The HTTP GET request * @param string $username The username of the user whose details are to be retrieved @@ -115,12 +120,12 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri } /** - * Retrieves a list of calendars for a specific user. + * Retrieves a list of calendars for a specific user, including user calendars, shared calendars, and subscriptions. * * @param Request $request The HTTP GET request * @param string $username The username of the user whose calendars are to be retrieved * - * @return JsonResponse A JSON response containing the list of calendars + * @return JsonResponse A JSON response containing the list of calendars for the specified user */ #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'])] public function getUserCalendars(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse @@ -189,7 +194,7 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi } /** - * Retrieves details of a specific calendar for a specific user. + * Retrieves details of a specific calendar for a specific user (id, uri, displayname, description, number of events, notes, and tasks). * * @param Request $request The HTTP GET request * @param string $username The username of the user whose calendar details are to be retrieved @@ -241,7 +246,7 @@ public function getUserCalendarDetails(Request $request, string $username, int $ } /** - * Retrieves a list of shares for a specific calendar of a specific user. + * Retrieves a list of shares for a specific calendar of a specific user (id, username, displayname, email, write_access). * * @param Request $request The HTTP GET request * @param string $username The username of the user whose calendar shares are to be retrieved From f75100835378c1da1d63958bd5a72dbd10f7beb3 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 25 Jan 2026 20:45:19 +0100 Subject: [PATCH 15/60] Updated API_KEY variable comment --- .env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 14faa72b..47f3c7ff 100644 --- a/.env +++ b/.env @@ -94,7 +94,7 @@ WEBDAV_HOMES_DIR= # When this variable is not empty, the /api endpoint becomes available. # This endpoint allows admins to perform certain actions that are normally only available # via the web dashboard. -# To generate a valid API_KEY you can use api:generate command +# To generate a valid API_KEY you can use the php bin/console api:generate command. API_KEY= # Logging path @@ -103,4 +103,4 @@ API_KEY= LOG_FILE_PATH="%kernel.logs_dir%/%kernel.environment%.log" # Trust the immediate proxy for X-Forwarded-* headers including HTTPS detection -SYMFONY_TRUSTED_PROXIES=REMOTE_ADDR \ No newline at end of file +SYMFONY_TRUSTED_PROXIES=REMOTE_ADDR From e17921f89e8678de1db30b71eaaa7ce8f86612b0 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 25 Jan 2026 21:22:07 +0100 Subject: [PATCH 16/60] Add missing request body constraints in docs --- docs/api/calendars/share_add.md | 17 +++++++++++++++++ docs/api/calendars/share_remove.md | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/api/calendars/share_add.md b/docs/api/calendars/share_add.md index ff60d3d0..c290efac 100644 --- a/docs/api/calendars/share_add.md +++ b/docs/api/calendars/share_add.md @@ -15,12 +15,29 @@ Shares (or updates write access) a calendar owned by the specified user to anoth :calendar_id -> "[numeric id of a calendar owned by the user]", ``` +** Request Body constraints** +```json +{ + "user_id": "[numeric id of the user to remove access]", + "write_access": "[boolean: true to grant write access, false for read-only]" +} +``` + **URL example** ```json /api/calendars/mdoe/share/1/add ``` +**Body example** + +```json +{ + "user_id": "3", + "write_access": true +} +``` + ## Success Response **Code** : `200 OK` diff --git a/docs/api/calendars/share_remove.md b/docs/api/calendars/share_remove.md index 182a00bc..71715596 100644 --- a/docs/api/calendars/share_remove.md +++ b/docs/api/calendars/share_remove.md @@ -15,12 +15,27 @@ Removes access to a specific shared calendar for a specific user. :calendar_id -> "[numeric id of a calendar owned by the user]", ``` +** Request Body Constraints** +```json +{ + "user_id": "[numeric id of the user to remove access]" +} +``` + **URL example** ```json /api/calendars/mdoe/share/1/remove ``` +**Body example** + +```json +{ + "user_id": "3", +} +``` + ## Success Response **Code** : `200 OK` From 56fca568fe78b09ec81677394a7d1b27e4412daf Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 12:23:12 +0100 Subject: [PATCH 17/60] Remove needed check/return as per PR comment no. 1 --- src/Controller/Api/ApiController.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 3c6615d8..576c97e0 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -58,10 +58,6 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo $principals = $doctrine->getRepository(Principal::class)->findByIsMain(true); - if (!$principals) { - return $this->json(['status' => 'success', 'data' => []], 200); - } - foreach ($principals as $principal) { $users[] = [ 'id' => $principal->getId(), From 06a0d16b6363abe72f83ad173522aed3eb38e50a Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 12:25:25 +0100 Subject: [PATCH 18/60] Add users array, as per PR comment no. 2 --- src/Controller/Api/ApiController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 576c97e0..bee87f6d 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -58,6 +58,7 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo $principals = $doctrine->getRepository(Principal::class)->findByIsMain(true); + $users = []; foreach ($principals as $principal) { $users[] = [ 'id' => $principal->getId(), From 57f04d0d9745357bc179c10294b4c2e8ac257302 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 12:26:00 +0100 Subject: [PATCH 19/60] Remove uneeded data false/null check, as per PR comment no. 3 --- src/Controller/Api/ApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index bee87f6d..cf79d68b 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -110,7 +110,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri $response = [ 'status' => 'success', - 'data' => $data ?? [], + 'data' => $data, ]; return $this->json($response, 200); From 5ece429f06550c1a403a244e2ba8553b1a556cb3 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 12:30:35 +0100 Subject: [PATCH 20/60] Changed return HTTP code, as per PR comment no. 4 --- src/Controller/Api/ApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index cf79d68b..56c5dba5 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -97,7 +97,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri $user = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username); if (!$user) { - return $this->json(['status' => 'success', 'data' => []], 200); + return $this->json(['status' => 'error', 'message' => 'User Not Found'], 404); } $data = [ From 8871163148601a19dfec43a7abbb6cb74f0fd692 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 12:35:37 +0100 Subject: [PATCH 21/60] Added helper function and moved checks to route requirements, as per PR comment 5 --- src/Controller/Api/ApiController.php | 51 +++++++--------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 56c5dba5..7dc32505 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -29,6 +29,11 @@ private function validateApiKey(Request $request): bool return hash_equals($this->apiKey, $key ?? ''); } + private function validateUsername(string $username): bool + { + return !empty($username) && is_string($username) && !preg_match('/[^a-zA-Z0-9_-]/', $username); + } + /** * Health check endpoint. * @@ -83,17 +88,13 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo * * @return JsonResponse A JSON response containing the user details */ - #[Route('/users/{username}', name: 'user_detail', methods: ['GET'])] + #[Route('/users/{username}', name: 'user_detail', methods: ['GET'], requirements: ['username' => "[a-zA-Z0-9_-]+"])] public function getUserDetails(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); - } - $user = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username); if (!$user) { @@ -124,17 +125,13 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri * * @return JsonResponse A JSON response containing the list of calendars for the specified user */ - #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'])] + #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'], requirements: ['username' => "[a-zA-Z0-9_-]+"])] public function getUserCalendars(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); - } - $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); $subscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri(Principal::PREFIX.$username); @@ -199,21 +196,13 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi * * @return JsonResponse A JSON response containing the calendar details */ - #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => "\d+"])] + #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] public function getUserCalendarDetails(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); - } - - if (empty($calendar_id) || !is_int($calendar_id)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID'], 400); - } - $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); if (!$allCalendars) { @@ -251,21 +240,13 @@ public function getUserCalendarDetails(Request $request, string $username, int $ * * @return JsonResponse A JSON response containing the list of calendar shares */ - #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => "\d+"])] + #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Username'], 400); - } - - if (!is_int($calendar_id)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID'], 400); - } - $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, @@ -310,17 +291,13 @@ public function getUserCalendarsShares(Request $request, string $username, int $ * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => "\d+"])] + #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] public function setUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); - } - $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, @@ -375,17 +352,13 @@ public function setUserCalendarsShare(Request $request, string $username, int $c * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => "\d+"])] + #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] public function removeUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - - if (empty($username) || !is_string($username) || preg_match('/[^a-zA-Z0-9_-]/', $username)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Username/Calendar ID'], 400); - } - + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, From c09e7a3eae247279660f57c453a51ec650c3055f Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 12:43:36 +0100 Subject: [PATCH 22/60] Added missing array vars and removed uneeded check, as per PR comment no. 6 --- src/Controller/Api/ApiController.php | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 7dc32505..c0af36bb 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -133,12 +133,10 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi } $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); - $subscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri(Principal::PREFIX.$username); - - if (!$allCalendars && !$subscriptions) { - return $this->json(['status' => 'success', 'data' => []], 200); - } + $allSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri(Principal::PREFIX.$username); + $calendars = []; + $sharedCalendars = []; foreach ($allCalendars as $calendar) { if (!$calendar->isShared()) { $calendars[] = [ @@ -163,8 +161,9 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi } } - foreach ($subscriptions as $subscription) { - $calendars[] = [ + $subscriptions = []; + foreach ($allSubscriptions as $subscription) { + $subscriptions[] = [ 'id' => $subscription->getId(), 'uri' => $subscription->getUri(), 'displayname' => $subscription->getDisplayName(), @@ -205,10 +204,6 @@ public function getUserCalendarDetails(Request $request, string $username, int $ $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); - if (!$allCalendars) { - return $this->json(['status' => 'success', 'data' => []], 200); - } - foreach ($allCalendars as $calendar) { if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { $calendar_details = [ @@ -258,10 +253,6 @@ public function getUserCalendarsShares(Request $request, string $username, int $ $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); - if (!$instances) { - return $this->json(['status' => 'success', 'data' => []], 200); - } - foreach ($instances as $instance) { $user_id = $doctrine->getRepository(Principal::class)->findOneByUri($instance[0]['principalUri']); @@ -358,7 +349,7 @@ public function removeUserCalendarsShare(Request $request, string $username, int if (!$this->validateApiKey($request)) { return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); } - + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, From 49cd4c5a2bb44c7f1062b9d77b59fc019cbebb52 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 13:19:48 +0100 Subject: [PATCH 23/60] Code Linting --- src/Controller/Api/ApiController.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index c0af36bb..3e896309 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -88,7 +88,7 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo * * @return JsonResponse A JSON response containing the user details */ - #[Route('/users/{username}', name: 'user_detail', methods: ['GET'], requirements: ['username' => "[a-zA-Z0-9_-]+"])] + #[Route('/users/{username}', name: 'user_detail', methods: ['GET'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] public function getUserDetails(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse { if (!$this->validateApiKey($request)) { @@ -125,7 +125,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri * * @return JsonResponse A JSON response containing the list of calendars for the specified user */ - #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'], requirements: ['username' => "[a-zA-Z0-9_-]+"])] + #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendars(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -195,7 +195,7 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi * * @return JsonResponse A JSON response containing the calendar details */ - #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] + #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendarDetails(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -235,7 +235,7 @@ public function getUserCalendarDetails(Request $request, string $username, int $ * * @return JsonResponse A JSON response containing the list of calendar shares */ - #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] + #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -282,7 +282,7 @@ public function getUserCalendarsShares(Request $request, string $username, int $ * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] + #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function setUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { @@ -343,7 +343,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => "[a-zA-Z0-9_-]+"])] + #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function removeUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$this->validateApiKey($request)) { From 8423f9e5394e58a0426c7468b25e10b5b5e4fee4 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 18:38:15 +0100 Subject: [PATCH 24/60] YAML decleration for custom API authenticator --- config/packages/security.yaml | 8 ++++++++ config/services.yaml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 7faaddf0..0975b80c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -8,6 +8,11 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + api: + pattern: ^/api + stateless: true + custom_authenticators: + - App\Security\ApiKeyAuthenticator main: lazy: true custom_authenticators: @@ -16,6 +21,7 @@ security: logout: path: app_logout target: dashboard + access_control: - { path: ^/$, roles: PUBLIC_ACCESS } @@ -24,3 +30,5 @@ security: - { path: ^/users, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/calendars, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/adressbooks, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } + - { path: ^/api, roles: IS_AUTHENTICATED } + - { path: ^/api/health$, roles: PUBLIC_ACCESS } diff --git a/config/services.yaml b/config/services.yaml index 7039d2b8..25662b12 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -78,9 +78,9 @@ services: arguments: $birthdayReminderOffset: "%birthday_reminder_offset%" - App\Controller\Api\ApiController: + App\Security\ApiKeyAuthenticator: arguments: - $apiKey: '%env(API_KEY)%' + $apiKey: "%env(API_KEY)%" when@dev: services: From cc998aa880989b2c29e724c84ac630f80884bc89 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 18:38:49 +0100 Subject: [PATCH 25/60] Custom API Authenticator logic --- src/Security/ApiKeyAuthenticator.php | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/Security/ApiKeyAuthenticator.php diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php new file mode 100644 index 00000000..c66679c4 --- /dev/null +++ b/src/Security/ApiKeyAuthenticator.php @@ -0,0 +1,58 @@ +apiKey = $apiKey; + } + + public function supports(Request $request): ?bool + { + // Always attempt to authenticate even if no API token is provided in the request + return true; + } + + public function authenticate(Request $request): Passport + { + $apiToken = $request->headers->get('X-Davis-API-Token'); + if (null === $apiToken) { + throw new CustomUserMessageAuthenticationException('No API token provided'); + } + + if ($apiToken !== $this->apiKey) { + throw new CustomUserMessageAuthenticationException('Invalid API token'); + } + + return new SelfValidatingPassport(new UserBadge('X-DAVIS-API')); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + 'message' => $exception->getMessage(), + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} From f7ae451ba93d72765fa377c701b3d1376d4e6c52 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 28 Jan 2026 18:39:36 +0100 Subject: [PATCH 26/60] Removed token verification from API endpoint & other cleanups --- src/Controller/Api/ApiController.php | 90 +++++++++++----------------- 1 file changed, 36 insertions(+), 54 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 3e896309..c8d47c99 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -15,23 +15,26 @@ #[Route('/api', name: 'api_')] class ApiController extends AbstractController { - private string $apiKey; - - public function __construct(string $apiKey) - { - $this->apiKey = $apiKey; - } - - private function validateApiKey(Request $request): bool + /** + * Validates the provided username. + * + * @param string $username The username to validate + * + * @return bool True if the username is valid, false otherwise + */ + private function validateUsername(string $username): bool { - $key = $request->headers->get('X-API-Key'); - - return hash_equals($this->apiKey, $key ?? ''); + return !empty($username) && is_string($username) && !preg_match('/[^a-zA-Z0-9_-]/', $username); } - private function validateUsername(string $username): bool + /** + * Gets the current timestamp in ISO 8601 format. + * + * @return string The current timestamp + */ + private function getTimestamp(): string { - return !empty($username) && is_string($username) && !preg_match('/[^a-zA-Z0-9_-]/', $username); + return date('c'); } /** @@ -44,7 +47,7 @@ private function validateUsername(string $username): bool #[Route('/health', name: 'health', methods: ['GET'])] public function healthCheck(Request $request): JsonResponse { - return $this->json(['status' => 'OK', 'timestamp' => date('c')], 200); + return $this->json(['status' => 'OK', 'timestamp' => $this->getTimestamp()], 200); } /** @@ -57,10 +60,6 @@ public function healthCheck(Request $request): JsonResponse #[Route('/users', name: 'users', methods: ['GET'])] public function getUsers(Request $request, ManagerRegistry $doctrine): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); - } - $principals = $doctrine->getRepository(Principal::class)->findByIsMain(true); $users = []; @@ -74,7 +73,8 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo $response = [ 'status' => 'success', - 'data' => $users ?? [], + 'data' => $users, + 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); @@ -91,14 +91,10 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo #[Route('/users/{username}', name: 'user_detail', methods: ['GET'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] public function getUserDetails(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); - } - $user = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username); if (!$user) { - return $this->json(['status' => 'error', 'message' => 'User Not Found'], 404); + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $data = [ @@ -112,6 +108,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri $response = [ 'status' => 'success', 'data' => $data, + 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); @@ -128,10 +125,6 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendars(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); - } - $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); $allSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri(Principal::PREFIX.$username); @@ -181,6 +174,7 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi 'shared_calendars' => $sharedCalendars ?? [], 'subscriptions' => $subscriptions ?? [], ], + 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); @@ -198,12 +192,9 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendarDetails(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); - } - $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); + $calendar_details = []; foreach ($allCalendars as $calendar) { if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { $calendar_details = [ @@ -220,7 +211,8 @@ public function getUserCalendarDetails(Request $request, string $username, int $ $response = [ 'status' => 'success', - 'data' => $calendar_details ?? [], + 'data' => $calendar_details, + 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); @@ -238,21 +230,18 @@ public function getUserCalendarDetails(Request $request, string $username, int $ #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); - } - $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); } $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); + $calendars = []; foreach ($instances as $instance) { $user_id = $doctrine->getRepository(Principal::class)->findOneByUri($instance[0]['principalUri']); @@ -267,7 +256,8 @@ public function getUserCalendarsShares(Request $request, string $username, int $ $response = [ 'status' => 'success', - 'data' => $calendars ?? [], + 'data' => $calendars, + 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); @@ -285,23 +275,19 @@ public function getUserCalendarsShares(Request $request, string $username, int $ #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function setUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); - } - $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); } $userId = $request->get('user_id'); $writeAccess = $request->get('write_access'); if (!is_numeric($userId) || !in_array($writeAccess, ['true', 'false'], true)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID/Write Access Value'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID/Write Access Value', 'timestamp' => $this->getTimestamp()], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); @@ -331,7 +317,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c } $entityManager->flush(); - return $this->json(['status' => 'success'], 200); + return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } /** @@ -346,29 +332,25 @@ public function setUserCalendarsShare(Request $request, string $username, int $c #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function removeUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { - if (!$this->validateApiKey($request)) { - return $this->json(['status' => 'error', 'message' => 'Unauthorized'], 401); - } - $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); } $userId = $request->get('user_id'); if (!is_numeric($userId)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID'], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID', 'timestamp' => $this->getTimestamp()], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($userId); if (!$instance || !$shareeToRemove) { - return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found'], 404); + return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $shareeToRemove->getUri()); @@ -379,6 +361,6 @@ public function removeUserCalendarsShare(Request $request, string $username, int $entityManager->flush(); } - return $this->json(['status' => 'success'], 200); + return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } } From 70ac8f2c70ac01e4c3bda7b957628f841cdce24d Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 29 Jan 2026 19:07:29 +0100 Subject: [PATCH 27/60] API Improvements: Unified username verification, endpoint allows for API versioning --- config/packages/security.yaml | 6 +++--- src/Controller/Api/ApiController.php | 32 ++++++++++++++++++++++------ src/Security/ApiKeyAuthenticator.php | 2 ++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 0975b80c..d0d3c5ab 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -8,8 +8,8 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false - api: - pattern: ^/api + api_v1: + pattern: ^/api/v1 stateless: true custom_authenticators: - App\Security\ApiKeyAuthenticator @@ -31,4 +31,4 @@ security: - { path: ^/calendars, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/adressbooks, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/api, roles: IS_AUTHENTICATED } - - { path: ^/api/health$, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/health$, roles: PUBLIC_ACCESS } diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index c8d47c99..c57f3cfd 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; -#[Route('/api', name: 'api_')] +#[Route('/api/v1', name: 'api_v1_')] class ApiController extends AbstractController { /** @@ -125,6 +125,10 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri #[Route('/calendars/{username}', name: 'calendars', methods: ['GET'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendars(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); $allSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri(Principal::PREFIX.$username); @@ -192,6 +196,10 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi #[Route('/calendars/{username}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendarDetails(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri(Principal::PREFIX.$username); $calendar_details = []; @@ -223,20 +231,24 @@ public function getUserCalendarDetails(Request $request, string $username, int $ * * @param Request $request The HTTP GET request * @param string $username The username of the user whose calendar shares are to be retrieved - * @param string $calendar_id The ID of the calendar whose shares are to be retrieved + * @param int $calendar_id The ID of the calendar whose shares are to be retrieved * * @return JsonResponse A JSON response containing the list of calendar shares */ #[Route('/calendars/{username}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function getUserCalendarsShares(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); } $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); @@ -275,13 +287,17 @@ public function getUserCalendarsShares(Request $request, string $username, int $ #[Route('/calendars/{username}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function setUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); } $userId = $request->get('user_id'); @@ -294,7 +310,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($userId); if (!$instance || !$newShareeToAdd) { - return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found'], 404); + return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); @@ -332,13 +348,17 @@ public function setUserCalendarsShare(Request $request, string $username, int $c #[Route('/calendars/{username}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function removeUserCalendarsShare(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => Principal::PREFIX.$username, ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); } $userId = $request->get('user_id'); diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index c66679c4..06a7854a 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -25,6 +25,7 @@ public function __construct(string $apiKey) public function supports(Request $request): ?bool { // Always attempt to authenticate even if no API token is provided in the request + // This stops the login page from being shown when accessing API routes return true; } @@ -51,6 +52,7 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio { $data = [ 'message' => $exception->getMessage(), + 'timestamp' => date('c'), ]; return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); From 019f6400f5567cd7b3c406258c682aec1c231821 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 29 Jan 2026 19:59:12 +0100 Subject: [PATCH 28/60] Fixed API Auth on /health endpoint --- src/Security/ApiKeyAuthenticator.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index 06a7854a..ee62ad36 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -24,6 +24,11 @@ public function __construct(string $apiKey) public function supports(Request $request): ?bool { + // Skip authentication for public health endpoint + if (preg_match('#^/api/v1/health$#', $request->getPathInfo())) { + return false; + } + // Always attempt to authenticate even if no API token is provided in the request // This stops the login page from being shown when accessing API routes return true; From d0cf2c774dcd008f224fd2fc804524272527d81d Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 29 Jan 2026 19:59:53 +0100 Subject: [PATCH 29/60] Added new API endpoint for creating new calendar instances --- src/Controller/Api/ApiController.php | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index c57f3cfd..b6b3e392 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -226,6 +226,68 @@ public function getUserCalendarDetails(Request $request, string $username, int $ return $this->json($response, 200); } + /** + * Creates a new calendar for a specific user. + * + * @param Request $request The HTTP POST request + * @param string $username The username of the user for whom the calendar is to be created + * + * @return JsonResponse A JSON response indicating the success or failure of the operation + */ + #[Route('/calendars/{username}/create', name: 'calendar_create', methods: ['POST'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] + public function createNewUserCalendar(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse + { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + + $calendarName = $request->get('name'); + if (preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName) !== 1) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); + } + $calendarURI = strtolower(str_replace(' ', '_', $calendarName)); + + $calendarDescription = $request->get('description', ''); + if (preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription) !== 1) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); + } + + $entityManager = $doctrine->getManager(); + $calendarInstance = new CalendarInstance(); + $calendar = new Calendar(); + $calendarInstance->setCalendar($calendar); + + $calendarComponents = []; + if ($request->get('events_support', 'true') === 'true') { + $calendarComponents[] = Calendar::COMPONENT_EVENTS; + } + if ($request->get('notes_support', 'false') === 'true') { + $calendarComponents[] = Calendar::COMPONENT_NOTES; + } + if ($request->get('tasks_support', 'false') === 'true') { + $calendarComponents[] = Calendar::COMPONENT_TODOS; + } + $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); + + $calendarInstance + ->setCalendar($calendar) + ->setAccess(CalendarInstance::ACCESS_SHAREDOWNER) + ->setDescription($calendarDescription) + ->setDisplayName($calendarName) + ->setUri($calendarURI) + ->setPrincipalUri(Principal::PREFIX.$username); + + $entityManager->persist($calendarInstance); + $entityManager->flush(); + + $response = [ + 'status' => 'success', + 'timestamp' => $this->getTimestamp(), + ]; + + return $this->json($response, 200); + } + /** * Retrieves a list of shares for a specific calendar of a specific user (id, username, displayname, email, write_access). * From 4ca0d842881ea8236e0dfe40b38f380187bdd0a9 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 00:56:24 +0100 Subject: [PATCH 30/60] Fix issue regarding AUTH for /api/v1/health endpoint --- config/packages/security.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index d0d3c5ab..3b697cb8 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -30,5 +30,5 @@ security: - { path: ^/users, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/calendars, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/adressbooks, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - - { path: ^/api, roles: IS_AUTHENTICATED } - { path: ^/api/v1/health$, roles: PUBLIC_ACCESS } + - { path: ^/api, roles: IS_AUTHENTICATED } From 8b7f8b37aa7c04fd85aa24ec96b89126d9e0063f Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 00:57:23 +0100 Subject: [PATCH 31/60] Polished existing endpoints & Added create/edit endpoint, --- src/Controller/Api/ApiController.php | 69 ++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index b6b3e392..f02cb04b 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -205,14 +205,24 @@ public function getUserCalendarDetails(Request $request, string $username, int $ $calendar_details = []; foreach ($allCalendars as $calendar) { if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { + $calendarComponents = explode(',', $calendar->getCalendar()->getComponents()); $calendar_details = [ 'id' => $calendar->getId(), 'uri' => $calendar->getUri(), 'displayname' => $calendar->getDisplayName(), 'description' => $calendar->getDescription(), - 'events' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), - 'notes' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), - 'tasks' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), + 'events' => [ + 'enabled' => in_array(Calendar::COMPONENT_EVENTS, $calendarComponents, true), + 'count' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), + ], + 'notes' => [ + 'enabled' => in_array(Calendar::COMPONENT_NOTES, $calendarComponents, true), + 'count' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), + ], + 'tasks' => [ + 'enabled' => in_array(Calendar::COMPONENT_TODOS, $calendarComponents, true), + 'count' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), + ], ]; } } @@ -226,6 +236,59 @@ public function getUserCalendarDetails(Request $request, string $username, int $ return $this->json($response, 200); } + #[Route('/calendars/{username}/{calendar_id}/edit', name: 'calendar_edit', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] + public function editUserCalendar(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse + { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ + 'id' => $calendar_id, + 'principalUri' => Principal::PREFIX.$username, + ]); + + if (!$ownerInstance) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); + } + + $calendarInstance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); + if (!$calendarInstance) { + return $this->json(['status' => 'error', 'message' => 'Calendar Instance Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + + $calendarName = $request->get('name'); + if (preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName) !== 1) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); + } + + $calendarDescription = $request->get('description', ''); + if (preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription) !== 1) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); + } + + $entityManager = $doctrine->getManager(); + $calendarInstance->setDisplayName($calendarName); + $calendarInstance->setDescription($calendarDescription); + + $calendarComponents = explode(',', $calendarInstance->getCalendar()->getComponents()); + if ($request->get('events_support', 'true') === 'true') { + $calendarComponents[] = Calendar::COMPONENT_EVENTS; + } + if ($request->get('notes_support', 'false') === 'true') { + $calendarComponents[] = Calendar::COMPONENT_NOTES; + } + if ($request->get('tasks_support', 'false') === 'true') { + $calendarComponents[] = Calendar::COMPONENT_TODOS; + } + $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); + + $entityManager->persist($calendarInstance); + $entityManager->flush(); + + return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); + } + /** * Creates a new calendar for a specific user. * From cf57f36028219f04ea4476c315f20bf556aa33e8 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 00:57:57 +0100 Subject: [PATCH 32/60] Code Linting --- src/Controller/Api/ApiController.php | 26 +++++++++++++------------- src/Security/ApiKeyAuthenticator.php | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index f02cb04b..c5e753b0 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -258,12 +258,12 @@ public function editUserCalendar(Request $request, string $username, int $calend } $calendarName = $request->get('name'); - if (preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName) !== 1) { + if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } $calendarDescription = $request->get('description', ''); - if (preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription) !== 1) { + if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); } @@ -272,13 +272,13 @@ public function editUserCalendar(Request $request, string $username, int $calend $calendarInstance->setDescription($calendarDescription); $calendarComponents = explode(',', $calendarInstance->getCalendar()->getComponents()); - if ($request->get('events_support', 'true') === 'true') { + if ('true' === $request->get('events_support', 'true')) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } - if ($request->get('notes_support', 'false') === 'true') { + if ('true' === $request->get('notes_support', 'false')) { $calendarComponents[] = Calendar::COMPONENT_NOTES; } - if ($request->get('tasks_support', 'false') === 'true') { + if ('true' === $request->get('tasks_support', 'false')) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); @@ -291,10 +291,10 @@ public function editUserCalendar(Request $request, string $username, int $calend /** * Creates a new calendar for a specific user. - * + * * @param Request $request The HTTP POST request * @param string $username The username of the user for whom the calendar is to be created - * + * * @return JsonResponse A JSON response indicating the success or failure of the operation */ #[Route('/calendars/{username}/create', name: 'calendar_create', methods: ['POST'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] @@ -305,13 +305,13 @@ public function createNewUserCalendar(Request $request, string $username, Manage } $calendarName = $request->get('name'); - if (preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName) !== 1) { + if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } $calendarURI = strtolower(str_replace(' ', '_', $calendarName)); $calendarDescription = $request->get('description', ''); - if (preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription) !== 1) { + if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); } @@ -321,17 +321,17 @@ public function createNewUserCalendar(Request $request, string $username, Manage $calendarInstance->setCalendar($calendar); $calendarComponents = []; - if ($request->get('events_support', 'true') === 'true') { + if ('true' === $request->get('events_support', 'true')) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } - if ($request->get('notes_support', 'false') === 'true') { + if ('true' === $request->get('notes_support', 'false')) { $calendarComponents[] = Calendar::COMPONENT_NOTES; } - if ($request->get('tasks_support', 'false') === 'true') { + if ('true' === $request->get('tasks_support', 'false')) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); - + $calendarInstance ->setCalendar($calendar) ->setAccess(CalendarInstance::ACCESS_SHAREDOWNER) diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index ee62ad36..bc027d6e 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -28,7 +28,7 @@ public function supports(Request $request): ?bool if (preg_match('#^/api/v1/health$#', $request->getPathInfo())) { return false; } - + // Always attempt to authenticate even if no API token is provided in the request // This stops the login page from being shown when accessing API routes return true; From 469046a36ff22aebad1726dd32e1089232d9c431 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 12:20:11 +0100 Subject: [PATCH 33/60] Updates to code structure placement order --- src/Controller/Api/ApiController.php | 108 +++++++++++++-------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index c5e753b0..9bdb387f 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -174,9 +174,9 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi $response = [ 'status' => 'success', 'data' => [ - 'user_calendars' => $calendars ?? [], - 'shared_calendars' => $sharedCalendars ?? [], - 'subscriptions' => $subscriptions ?? [], + 'user_calendars' => $calendars, + 'shared_calendars' => $sharedCalendars, + 'subscriptions' => $subscriptions, ], 'timestamp' => $this->getTimestamp(), ]; @@ -236,31 +236,26 @@ public function getUserCalendarDetails(Request $request, string $username, int $ return $this->json($response, 200); } - #[Route('/calendars/{username}/{calendar_id}/edit', name: 'calendar_edit', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] - public function editUserCalendar(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse + /** + * Creates a new calendar for a specific user. + * + * @param Request $request The HTTP POST request + * @param string $username The username of the user for whom the calendar is to be created + * + * @return JsonResponse A JSON response indicating the success or failure of the operation + */ + #[Route('/calendars/{username}/create', name: 'calendar_create', methods: ['POST'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] + public function createNewUserCalendar(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } - $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ - 'id' => $calendar_id, - 'principalUri' => Principal::PREFIX.$username, - ]); - - if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); - } - - $calendarInstance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); - if (!$calendarInstance) { - return $this->json(['status' => 'error', 'message' => 'Calendar Instance Not Found', 'timestamp' => $this->getTimestamp()], 404); - } - $calendarName = $request->get('name'); if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } + $calendarURI = strtolower(str_replace(' ', '_', $calendarName)); $calendarDescription = $request->get('description', ''); if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription)) { @@ -268,10 +263,11 @@ public function editUserCalendar(Request $request, string $username, int $calend } $entityManager = $doctrine->getManager(); - $calendarInstance->setDisplayName($calendarName); - $calendarInstance->setDescription($calendarDescription); + $calendarInstance = new CalendarInstance(); + $calendar = new Calendar(); + $calendarInstance->setCalendar($calendar); - $calendarComponents = explode(',', $calendarInstance->getCalendar()->getComponents()); + $calendarComponents = []; if ('true' === $request->get('events_support', 'true')) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } @@ -283,32 +279,50 @@ public function editUserCalendar(Request $request, string $username, int $calend } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); + $calendarInstance + ->setCalendar($calendar) + ->setAccess(CalendarInstance::ACCESS_SHAREDOWNER) + ->setDescription($calendarDescription) + ->setDisplayName($calendarName) + ->setUri($calendarURI) + ->setPrincipalUri(Principal::PREFIX.$username); + $entityManager->persist($calendarInstance); $entityManager->flush(); - return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); + $response = [ + 'status' => 'success', + 'timestamp' => $this->getTimestamp(), + ]; + + return $this->json($response, 200); } - /** - * Creates a new calendar for a specific user. - * - * @param Request $request The HTTP POST request - * @param string $username The username of the user for whom the calendar is to be created - * - * @return JsonResponse A JSON response indicating the success or failure of the operation - */ - #[Route('/calendars/{username}/create', name: 'calendar_create', methods: ['POST'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] - public function createNewUserCalendar(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse + #[Route('/calendars/{username}/{calendar_id}/edit', name: 'calendar_edit', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] + public function editUserCalendar(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } + $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ + 'id' => $calendar_id, + 'principalUri' => Principal::PREFIX.$username, + ]); + + if (!$ownerInstance) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); + } + + $calendarInstance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); + if (!$calendarInstance) { + return $this->json(['status' => 'error', 'message' => 'Calendar Instance Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + $calendarName = $request->get('name'); if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } - $calendarURI = strtolower(str_replace(' ', '_', $calendarName)); $calendarDescription = $request->get('description', ''); if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription)) { @@ -316,11 +330,10 @@ public function createNewUserCalendar(Request $request, string $username, Manage } $entityManager = $doctrine->getManager(); - $calendarInstance = new CalendarInstance(); - $calendar = new Calendar(); - $calendarInstance->setCalendar($calendar); + $calendarInstance->setDisplayName($calendarName); + $calendarInstance->setDescription($calendarDescription); - $calendarComponents = []; + $calendarComponents = explode(',', $calendarInstance->getCalendar()->getComponents()); if ('true' === $request->get('events_support', 'true')) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } @@ -332,23 +345,10 @@ public function createNewUserCalendar(Request $request, string $username, Manage } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); - $calendarInstance - ->setCalendar($calendar) - ->setAccess(CalendarInstance::ACCESS_SHAREDOWNER) - ->setDescription($calendarDescription) - ->setDisplayName($calendarName) - ->setUri($calendarURI) - ->setPrincipalUri(Principal::PREFIX.$username); - $entityManager->persist($calendarInstance); $entityManager->flush(); - $response = [ - 'status' => 'success', - 'timestamp' => $this->getTimestamp(), - ]; - - return $this->json($response, 200); + return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } /** @@ -373,7 +373,7 @@ public function getUserCalendarsShares(Request $request, string $username, int $ ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); } $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); @@ -422,7 +422,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c ]); if (!$ownerInstance) { - return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID and User ID', 'timestamp' => $this->getTimestamp()], 400); } $userId = $request->get('user_id'); From bc7c950835c4043f0a89953e8789b60baeceb0c8 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 12:20:44 +0100 Subject: [PATCH 34/60] Updated README.md and API docs to match changes and additions --- README.md | 2 +- docs/api/README.md | 30 ++--- docs/api/calendars/share_add.md | 105 ----------------- docs/api/calendars/share_remove.md | 103 ----------------- docs/api/{ => v1}/calendars/all.md | 40 ++++--- docs/api/v1/calendars/create.md | 124 ++++++++++++++++++++ docs/api/{ => v1}/calendars/details.md | 54 ++++----- docs/api/v1/calendars/edit.md | 153 +++++++++++++++++++++++++ docs/api/v1/calendars/share_add.md | 132 +++++++++++++++++++++ docs/api/v1/calendars/share_remove.md | 130 +++++++++++++++++++++ docs/api/{ => v1}/calendars/shares.md | 44 +++---- docs/api/{ => v1}/health.md | 2 +- docs/api/{ => v1}/users/all.md | 25 ++-- docs/api/{ => v1}/users/details.md | 39 ++++--- 14 files changed, 660 insertions(+), 323 deletions(-) delete mode 100644 docs/api/calendars/share_add.md delete mode 100644 docs/api/calendars/share_remove.md rename docs/api/{ => v1}/calendars/all.md (68%) create mode 100644 docs/api/v1/calendars/create.md rename docs/api/{ => v1}/calendars/details.md (53%) create mode 100644 docs/api/v1/calendars/edit.md create mode 100644 docs/api/v1/calendars/share_add.md create mode 100644 docs/api/v1/calendars/share_remove.md rename docs/api/{ => v1}/calendars/shares.md (54%) rename docs/api/{ => v1}/health.md (89%) rename docs/api/{ => v1}/users/all.md (50%) rename docs/api/{ => v1}/users/details.md (55%) diff --git a/README.md b/README.md index 4563e3b2..8e79fe4a 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ For user and calendar management there is an API endpoint. See [the API document > [!TIP] > -> The API endpoint requires an environment variable `API_KEY` set to a secret key that you will use in the `X-API-Key` header of your requests to authenticate. You can generate it with `bin/console api:generate` +> The API endpoint requires an environment variable `API_KEY` set to a secret key that you will use in the `X-Davis-API-Token` header of your requests to authenticate. You can generate it with `bin/console api:generate` ## Webserver Configuration Examples diff --git a/docs/api/README.md b/docs/api/README.md index 427dcc90..1b134ad4 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,28 +1,32 @@ # Davis API -## Open Endpoints +## API Version 1 + +### Open Endpoints Open endpoints require no Authentication. -* [Health](health.md) : `GET /api/health` +* [Health](v1/health.md) : `GET /api/v1/health` -## Endpoints that require Authentication +### Endpoints that require Authentication -Closed endpoints require a valid `X-API-Key` to be included in the header of the request. Token needs to be configured in .env file (as a environment variable `API_KEY`) and can be generated using `php bin/console api:generate` command. +Closed endpoints require a valid `X-Davis-API-Token` to be included in the header of the request. Token needs to be configured in .env file (as a environment variable `API_KEY`) and can be generated using `php bin/console api:generate` command. -### User related +#### User related Each endpoint displays information related to the User: -* [Get Users](users/all.md) : `GET /api/users` -* [Get User Details](users/details.md) : `GET /api/users/:username` +* [Get Users](v1/users/all.md) : `GET /api/v1/users` +* [Get User Details](v1/users/details.md) : `GET /api/v1/users/:username` -### Calendars related +#### Calendars related Endpoints for viewing and modifying user calendars. -* [Show All User Calendars](calendars/all.md) : `GET /api/calendars/:username` -* [Show User Calendar Details](calendars/details.md) : `GET /api/calendars/:username/:calendar_id` -* [Show User Calendar Shares](calendars/shares.md) : `GET /api/calendars/:username/shares/:calendar_id` -* [Share User Calendar](calendars/share_add.md) : `POST /api/calendars/:username/share/:calendar_id/add` -* [Remove Share User Calendar](calendars/share_remove.md) : `POST /api/calendars/:username/share/:calendar_id/remove` \ No newline at end of file +* [Show All User Calendars](v1/calendars/all.md) : `GET /api/v1/calendars/:username` +* [Show User Calendar Details](v1/calendars/details.md) : `GET /api/v1/calendars/:username/:calendar_id` +* [Create User Calendar](v1/calendars/create.md) : `POST /api/v1/calendars/:username/create` +* [Edit User Calendar](v1/calendars/edit.md) : `POST /api/v1/calendars/:username/:calendar_id/edit` +* [Show User Calendar Shares](v1/calendars/shares.md) : `GET /api/v1/calendars/:username/shares/:calendar_id` +* [Share User Calendar](v1/calendars/share_add.md) : `POST /api/v1/calendars/:username/share/:calendar_id/add` +* [Remove Share User Calendar](v1/calendars/share_remove.md) : `POST /api/v1/calendars/:username/share/:calendar_id/remove` diff --git a/docs/api/calendars/share_add.md b/docs/api/calendars/share_add.md deleted file mode 100644 index c290efac..00000000 --- a/docs/api/calendars/share_add.md +++ /dev/null @@ -1,105 +0,0 @@ -# Share User Calendar - -Shares (or updates write access) a calendar owned by the specified user to another user. - -**URL** : `/api/calendars/:username/share/:calendar_id/add` - -**Method** : `POST` - -**Auth required** : YES - -**Params constraints** - -``` -:username -> "[username in plain text]", -:calendar_id -> "[numeric id of a calendar owned by the user]", -``` - -** Request Body constraints** -```json -{ - "user_id": "[numeric id of the user to remove access]", - "write_access": "[boolean: true to grant write access, false for read-only]" -} -``` - -**URL example** - -```json -/api/calendars/mdoe/share/1/add -``` - -**Body example** - -```json -{ - "user_id": "3", - "write_access": true -} -``` - -## Success Response - -**Code** : `200 OK` - -**Content examples** - -```json -{ - "status": "success" -} -``` - -## Error Response - -**Condition** : If 'X-API-Key' is not present or mismatched in headers. - -**Code** : `401 UNAUTHORIZED` - -**Content** : - -```json -{ - "status": "error", - "message": "Unauthorized" -} -``` - -**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. - -**Code** : `400 BAD REQUEST` - -**Content** : - -```json -{ - "status": "error", - "message": "Invalid Username" -} -``` - -**Condition** : If ':calendar_id' is not a valid numeric value. - -**Code** : `400 BAD REQUEST` - -**Content** : - -```json -{ - "status": "error", - "message": "Invalid Calendar ID" -} -``` - -**Condition** : If ':calendar_id' is not for the specified ':username'. - -**Code** : `400 BAD REQUEST` - -**Content** : - -```json -{ - "status": "error", - "message": "Invalid Calendar ID/Username" -} -``` \ No newline at end of file diff --git a/docs/api/calendars/share_remove.md b/docs/api/calendars/share_remove.md deleted file mode 100644 index 71715596..00000000 --- a/docs/api/calendars/share_remove.md +++ /dev/null @@ -1,103 +0,0 @@ -# Remove Share User Calendar - -Removes access to a specific shared calendar for a specific user. - -**URL** : `/api/calendars/:username/share/:calendar_id/remove` - -**Method** : `POST` - -**Auth required** : YES - -**Params constraints** - -``` -:username -> "[username in plain text]", -:calendar_id -> "[numeric id of a calendar owned by the user]", -``` - -** Request Body Constraints** -```json -{ - "user_id": "[numeric id of the user to remove access]" -} -``` - -**URL example** - -```json -/api/calendars/mdoe/share/1/remove -``` - -**Body example** - -```json -{ - "user_id": "3", -} -``` - -## Success Response - -**Code** : `200 OK` - -**Content examples** - -```json -{ - "status": "success" -} -``` - -## Error Response - -**Condition** : If 'X-API-Key' is not present or mismatched in headers. - -**Code** : `401 UNAUTHORIZED` - -**Content** : - -```json -{ - "status": "error", - "message": "Unauthorized" -} -``` - -**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. - -**Code** : `400 BAD REQUEST` - -**Content** : - -```json -{ - "status": "error", - "message": "Invalid Username" -} -``` - -**Condition** : If ':calendar_id' is not a valid numeric value. - -**Code** : `400 BAD REQUEST` - -**Content** : - -```json -{ - "status": "error", - "message": "Invalid Calendar ID" -} -``` - -**Condition** : If ':calendar_id' is not for the specified ':username'. - -**Code** : `400 BAD REQUEST` - -**Content** : - -```json -{ - "status": "error", - "message": "Invalid Calendar ID/Username" -} -``` \ No newline at end of file diff --git a/docs/api/calendars/all.md b/docs/api/v1/calendars/all.md similarity index 68% rename from docs/api/calendars/all.md rename to docs/api/v1/calendars/all.md index 4d9ced5f..df0e2642 100644 --- a/docs/api/calendars/all.md +++ b/docs/api/v1/calendars/all.md @@ -2,7 +2,7 @@ Gets a list of all available calendars for a specific user. -**URL** : `/api/calendars/:username` +**URL** : `/api/v1/calendars/:username` **Method** : `GET` @@ -17,7 +17,7 @@ Gets a list of all available calendars for a specific user. **URL example** ```json -/api/calendars/jdoe +/api/v1/calendars/jdoe ``` ## Success Response @@ -53,15 +53,8 @@ Gets a list of all available calendars for a specific user. } ], "subscriptions": [] - } -} -``` - -Shown when there are no users in Davis: -```json -{ - "status": "success", - "data": [] + }, + "timestamp": "2026-01-23T15:01:33+01:00" } ``` @@ -73,13 +66,14 @@ Shown when user does not have calendars: "user_calendars": [], "shared_calendars": [], "subscriptions": [] - } + }, + "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response -**Condition** : If 'X-API-Key' is not present or mismatched in headers. +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` @@ -87,20 +81,30 @@ Shown when user does not have calendars: ```json { - "status": "error", - "message": "Unauthorized" + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` -**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. +**Condition** : If user is not found. -**Code** : `400 BAD REQUEST` +**Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", - "message": "Invalid Username" + "message": "User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` \ No newline at end of file diff --git a/docs/api/v1/calendars/create.md b/docs/api/v1/calendars/create.md new file mode 100644 index 00000000..0c2ce075 --- /dev/null +++ b/docs/api/v1/calendars/create.md @@ -0,0 +1,124 @@ +# Create User Calendar + +Creates a new calendar for a specific user. + +**URL** : `/api/v1/calendars/:username/create` + +**Method** : `POST` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +``` + +**Request Body constraints** + +```json +{ + "name": "[string: calendar name, alphanumeric, spaces, underscores and hyphens, max 64 chars]", + "description": "[string: calendar description, alphanumeric, spaces, underscores and hyphens, max 256 chars, optional]", + "events_support": "[string: 'true' or 'false', default 'true', optional]", + "notes_support": "[string: 'true' or 'false', default 'false', optional]", + "tasks_support": "[string: 'true' or 'false', default 'false', optional]" +} +``` + +**URL example** + +``` +/api/v1/calendars/jdoe/create +``` + +**Body example** + +```json +{ + "name": "Work Calendar", + "description": "Calendar for work events", + "events_support": "true", + "notes_support": "false", + "tasks_support": "true" +} +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +## Error Response + +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If user is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'name' parameter is invalid (not matching the regex or exceeds length). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar Name", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'description' parameter is invalid (not matching the regex or exceeds length). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar Description", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` diff --git a/docs/api/calendars/details.md b/docs/api/v1/calendars/details.md similarity index 53% rename from docs/api/calendars/details.md rename to docs/api/v1/calendars/details.md index 1c1193fa..ec91acce 100644 --- a/docs/api/calendars/details.md +++ b/docs/api/v1/calendars/details.md @@ -2,7 +2,7 @@ Gets a list of all available calendars for a specific user. -**URL** : `/api/calendars/:username/:calendar_id` +**URL** : `/api/v1/calendars/:username/:calendar_id` **Method** : `GET` @@ -18,7 +18,7 @@ Gets a list of all available calendars for a specific user. **URL example** ```json -/api/calendars/jdoe/1 +/api/v1/calendars/jdoe/1 ``` ## Success Response @@ -35,10 +35,20 @@ Gets a list of all available calendars for a specific user. "uri": "default", "displayname": "Default Calendar", "description": "Default Calendar for Joe Doe", - "events": 0, - "notes": 0, - "tasks": 0 - } + "events": { + "enabled": true, + "count": 0 + }, + "notes": { + "enabled": false, + "count": 0 + }, + "tasks": { + "enabled": false, + "count": 0 + } + }, + "timestamp": "2026-01-23T15:01:33+01:00" } ``` @@ -46,13 +56,14 @@ Shown when user has no calendars with the given id: ```json { "status": "success", - "data": [] + "data": {}, + "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response -**Condition** : If 'X-API-Key' is not present or mismatched in headers. +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` @@ -60,33 +71,16 @@ Shown when user has no calendars with the given id: ```json { - "status": "error", - "message": "Unauthorized" + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` -**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. - -**Code** : `400 BAD REQUEST` - -**Content** : - -```json -{ - "status": "error", - "message": "Invalid Username" -} -``` - -**Condition** : If ':calendar_id' is not a valid numeric value. - -**Code** : `400 BAD REQUEST` - -**Content** : +or ```json { - "status": "error", - "message": "Invalid Calendar ID" + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` \ No newline at end of file diff --git a/docs/api/v1/calendars/edit.md b/docs/api/v1/calendars/edit.md new file mode 100644 index 00000000..4388b97b --- /dev/null +++ b/docs/api/v1/calendars/edit.md @@ -0,0 +1,153 @@ +# Edit User Calendar + +Edits an existing calendar for a specific user. + +**URL** : `/api/v1/calendars/:username/:calendar_id/edit` + +**Method** : `POST` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +:calendar_id -> "[numeric id of a calendar owned by the user]", +``` + +**Request Body constraints** + +```json +{ + "name": "[string: calendar name, alphanumeric, spaces, underscores and hyphens, max 64 chars]", + "description": "[string: calendar description, alphanumeric, spaces, underscores and hyphens, max 256 chars, optional]", + "events_support": "[string: 'true' or 'false', default 'true', optional]", + "notes_support": "[string: 'true' or 'false', default 'false', optional]", + "tasks_support": "[string: 'true' or 'false', default 'false', optional]" +} +``` + +**URL example** + +``` +/api/v1/calendars/jdoe/1/edit +``` + +**Body example** + +```json +{ + "name": "Updated Work Calendar", + "description": "Updated calendar for work events", + "events_support": "true", + "notes_support": "true", + "tasks_support": "false" +} +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +## Error Response + +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If user is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If ':calendar_id' is not owned by the specified ':username'. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If calendar instance is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "Calendar Instance Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'name' parameter is invalid (not matching the regex or exceeds length). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar Name", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'description' parameter is invalid (not matching the regex or exceeds length). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar Description", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` \ No newline at end of file diff --git a/docs/api/v1/calendars/share_add.md b/docs/api/v1/calendars/share_add.md new file mode 100644 index 00000000..988b083b --- /dev/null +++ b/docs/api/v1/calendars/share_add.md @@ -0,0 +1,132 @@ +# Share User Calendar + +Shares (or updates write access) a calendar owned by the specified user to another user. + +**URL** : `/api/v1/calendars/:username/share/:calendar_id/add` + +**Method** : `POST` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +:calendar_id -> "[numeric id of a calendar owned by the user]", +``` + +** Request Body constraints** +```json +{ + "user_id": "[numeric id of the user to remove access]", + "write_access": "[boolean: true to grant write access, false for read-only]" +} +``` + +**URL example** + +```json +/api/v1/calendars/mdoe/share/1/add +``` + +**Body example** + +```json +{ + "user_id": "3", + "write_access": true +} +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +## Error Response + +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If user is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If ':calendar_id' is not owned by the specified ':username'. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID and User ID", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'user_id' is not numeric or 'write_access' is not 'true' or 'false' string. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Sharee ID/Write Access Value", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If calendar instance or user to share with is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "Calendar Instance/User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` \ No newline at end of file diff --git a/docs/api/v1/calendars/share_remove.md b/docs/api/v1/calendars/share_remove.md new file mode 100644 index 00000000..5cc41870 --- /dev/null +++ b/docs/api/v1/calendars/share_remove.md @@ -0,0 +1,130 @@ +# Remove Share User Calendar + +Removes access to a specific shared calendar for a specific user. + +**URL** : `/api/v1/calendars/:username/share/:calendar_id/remove` + +**Method** : `POST` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +:calendar_id -> "[numeric id of a calendar owned by the user]", +``` + +** Request Body Constraints** +```json +{ + "user_id": "[numeric id of the user to remove access]" +} +``` + +**URL example** + +```json +/api/v1/calendars/mdoe/share/1/remove +``` + +**Body example** + +```json +{ + "user_id": "3", +} +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +## Error Response + +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If user is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If ':calendar_id' is not owned by the specified ':username'. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'user_id' is not numeric. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Sharee ID", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If calendar instance or user to remove is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "Calendar Instance/User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` \ No newline at end of file diff --git a/docs/api/calendars/shares.md b/docs/api/v1/calendars/shares.md similarity index 54% rename from docs/api/calendars/shares.md rename to docs/api/v1/calendars/shares.md index c9f45122..42b6b9a3 100644 --- a/docs/api/calendars/shares.md +++ b/docs/api/v1/calendars/shares.md @@ -2,7 +2,7 @@ Gets a list of all users with whom a specific user calendar is shared. -**URL** : `/api/calendars/:username/shares/:calendar_id` +**URL** : `/api/v1/calendars/:username/shares/:calendar_id` **Method** : `GET` @@ -18,7 +18,7 @@ Gets a list of all users with whom a specific user calendar is shared. **URL example** ```json -/api/calendars/mdoe/shares/1 +/api/v1/calendars/mdoe/shares/1 ``` ## Success Response @@ -49,17 +49,9 @@ Gets a list of all users with whom a specific user calendar is shared. } ``` -Shown when user has no calendars with the given id: -```json -{ - "status": "success", - "data": [] -} -``` - ## Error Response -**Condition** : If 'X-API-Key' is not present or mismatched in headers. +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` @@ -67,38 +59,35 @@ Shown when user has no calendars with the given id: ```json { - "status": "error", - "message": "Unauthorized" + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` -**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. - -**Code** : `400 BAD REQUEST` - -**Content** : +or ```json { - "status": "error", - "message": "Invalid Username" + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` -**Condition** : If ':calendar_id' is not a valid numeric value. +**Condition** : If user is not found. -**Code** : `400 BAD REQUEST` +**Code** : `404 NOT FOUND` **Content** : ```json { - "status": "error", - "message": "Invalid Calendar ID" + "status": "error", + "message": "User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` -**Condition** : If ':calendar_id' is not for the specified ':username'. +**Condition** : If ':calendar_id' and ':username' combination is invalid. **Code** : `400 BAD REQUEST` @@ -106,7 +95,8 @@ Shown when user has no calendars with the given id: ```json { - "status": "error", - "message": "Invalid Calendar ID/Username" + "status": "error", + "message": "Invalid Calendar ID/Username", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` \ No newline at end of file diff --git a/docs/api/health.md b/docs/api/v1/health.md similarity index 89% rename from docs/api/health.md rename to docs/api/v1/health.md index cd765e55..b20197ec 100644 --- a/docs/api/health.md +++ b/docs/api/v1/health.md @@ -2,7 +2,7 @@ Used to check if the API endpoint is active. -**URL** : `/api/health/` +**URL** : `/api/v1/health` **Method** : `GET` diff --git a/docs/api/users/all.md b/docs/api/v1/users/all.md similarity index 50% rename from docs/api/users/all.md rename to docs/api/v1/users/all.md index 997e62ab..f438d0f2 100644 --- a/docs/api/users/all.md +++ b/docs/api/v1/users/all.md @@ -2,7 +2,7 @@ Gets a list of all available users. -**URL** : `/api/users` +**URL** : `/api/v1/users` **Method** : `GET` @@ -21,9 +21,10 @@ Gets a list of all available users. { "id": 3, "uri": "principals/jdoe", - "username": "jdoe", + "username": "jdoe" } - ] + ], + "timestamp": "2026-01-23T15:01:33+01:00" } ``` @@ -31,13 +32,14 @@ Shown when there are no users in Davis: ```json { "status": "success", - "data": [] + "data": [], + "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response -**Condition** : If 'X-API-Key' is not present or mismatched in headers. +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` @@ -45,7 +47,16 @@ Shown when there are no users in Davis: ```json { - "status": "error", - "message": "Unauthorized" + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` diff --git a/docs/api/users/details.md b/docs/api/v1/users/details.md similarity index 55% rename from docs/api/users/details.md rename to docs/api/v1/users/details.md index 7a7f7ee8..35baf7e4 100644 --- a/docs/api/users/details.md +++ b/docs/api/v1/users/details.md @@ -2,7 +2,7 @@ Gets details about a specific user account. -**URL** : `/api/users/:username` +**URL** : `/api/v1/users/:username` **Method** : `GET` @@ -17,7 +17,7 @@ Gets details about a specific user account. **URL example** ```json -/api/users/jdoe +/api/v1/users/jdoe ``` ## Success Response @@ -35,21 +35,14 @@ Gets details about a specific user account. "username": "jdoe", "displayname": "John Doe", "email": "jdoe@example.org" - } -} -``` - -Shown when there are no users in Davis: -```json -{ - "status": "success", - "data": [] + }, + "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response -**Condition** : If 'X-API-Key' is not present or mismatched in headers. +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` @@ -57,19 +50,29 @@ Shown when there are no users in Davis: ```json { - "status": "error", - "message": "Unauthorized" + "message": "No API token provided", + "timestamp": "2026-01-23T15:01:33+01:00" } ``` -**Condition** : If ':username' is not a valid string containing chars: `a-zA-Z0-9_-`. +or -**Code** : `400 BAD REQUEST` +```json +{ + "message": "Invalid API token", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If user is not found. + +**Code** : `404 NOT FOUND` **Content** : ```json { - "status": "error", - "message": "Invalid Username" + "status": "error", + "message": "User Not Found", + "timestamp": "2026-01-23T15:01:33+01:00" } \ No newline at end of file From ac84c3ca9b11f45e61912c603ae310b92a781dec Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 20:05:07 +0100 Subject: [PATCH 35/60] Bail on empty API_KEY & moved to hash_equals for string comparison --- src/Security/ApiKeyAuthenticator.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index bc027d6e..76a14bfe 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -19,6 +19,11 @@ class ApiKeyAuthenticator extends AbstractAuthenticator public function __construct(string $apiKey) { + // Disable API endpoint if no API key is set + if (hash_equals('', trim($apiKey))) { + throw new \LogicException('API_KEY environment variable must not be empty. API endpoint is disabled.'); + } + $this->apiKey = $apiKey; } @@ -38,11 +43,11 @@ public function authenticate(Request $request): Passport { $apiToken = $request->headers->get('X-Davis-API-Token'); if (null === $apiToken) { - throw new CustomUserMessageAuthenticationException('No API token provided'); + throw new CustomUserMessageAuthenticationException('Missing X-Davis-API-Token header'); } - if ($apiToken !== $this->apiKey) { - throw new CustomUserMessageAuthenticationException('Invalid API token'); + if (hash_equals($this->apiKey, $apiToken) === false) { + throw new CustomUserMessageAuthenticationException('Invalid X-Davis-API-Token header'); } return new SelfValidatingPassport(new UserBadge('X-DAVIS-API')); From 73e3d6a935e0d8e9430367bd3b2ac8ccb8f0e41c Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 20:07:02 +0100 Subject: [PATCH 36/60] Add to API docs, information about API handling when API_KEY is empty --- docs/api/README.md | 2 ++ src/Security/ApiKeyAuthenticator.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/api/README.md b/docs/api/README.md index 1b134ad4..f75f9ed7 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -12,6 +12,8 @@ Open endpoints require no Authentication. Closed endpoints require a valid `X-Davis-API-Token` to be included in the header of the request. Token needs to be configured in .env file (as a environment variable `API_KEY`) and can be generated using `php bin/console api:generate` command. +When `API_KEY` is not set, the API endpoints are disabled and will return a 500 error if accessed. + #### User related Each endpoint displays information related to the User: diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index 76a14bfe..94ae56b9 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -21,7 +21,7 @@ public function __construct(string $apiKey) { // Disable API endpoint if no API key is set if (hash_equals('', trim($apiKey))) { - throw new \LogicException('API_KEY environment variable must not be empty. API endpoint is disabled.'); + throw new \LogicException('API endpoint is disabled.'); } $this->apiKey = $apiKey; From e17976845f1becec99461e048f8979b5bc3f80cc Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 20:25:11 +0100 Subject: [PATCH 37/60] Code Linting --- src/Security/ApiKeyAuthenticator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index 94ae56b9..1a6ede40 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -23,7 +23,7 @@ public function __construct(string $apiKey) if (hash_equals('', trim($apiKey))) { throw new \LogicException('API endpoint is disabled.'); } - + $this->apiKey = $apiKey; } @@ -46,7 +46,7 @@ public function authenticate(Request $request): Passport throw new CustomUserMessageAuthenticationException('Missing X-Davis-API-Token header'); } - if (hash_equals($this->apiKey, $apiToken) === false) { + if (false === hash_equals($this->apiKey, $apiToken)) { throw new CustomUserMessageAuthenticationException('Invalid X-Davis-API-Token header'); } From 667260bc17222b89476e39891af8a9942c52d0e7 Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 1 Feb 2026 20:25:43 +0100 Subject: [PATCH 38/60] Moved object count to ORM query, added helper function to check if component is enabled in calendar --- src/Controller/Api/ApiController.php | 51 +++++++++---------- src/Entity/Calendar.php | 12 +++++ src/Repository/CalendarInstanceRepository.php | 42 +++++++++++++++ 3 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 9bdb387f..6271f360 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -135,39 +135,34 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi $calendars = []; $sharedCalendars = []; foreach ($allCalendars as $calendar) { + $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($calendar->getCalendar()->getId()); + $calendarData = [ + 'id' => $calendar->getId(), + 'uri' => $calendar->getUri(), + 'displayname' => $calendar->getDisplayName(), + 'description' => $calendar->getDescription(), + 'events' => $objectCounts['events'], + 'notes' => $objectCounts['notes'], + 'tasks' => $objectCounts['tasks'], + ]; if (!$calendar->isShared()) { - $calendars[] = [ - 'id' => $calendar->getId(), - 'uri' => $calendar->getUri(), - 'displayname' => $calendar->getDisplayName(), - 'description' => $calendar->getDescription(), - 'events' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), - 'notes' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), - 'tasks' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), - ]; + $calendars[] = $calendarData; } else { - $sharedCalendars[] = [ - 'id' => $calendar->getId(), - 'uri' => $calendar->getUri(), - 'displayname' => $calendar->getDisplayName(), - 'description' => $calendar->getDescription(), - 'events' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), - 'notes' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), - 'tasks' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), - ]; + $sharedCalendars[] = $calendarData; } } $subscriptions = []; foreach ($allSubscriptions as $subscription) { + $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($subscription->getCalendar()->getId()); $subscriptions[] = [ 'id' => $subscription->getId(), 'uri' => $subscription->getUri(), 'displayname' => $subscription->getDisplayName(), 'description' => $subscription->getDescription(), - 'events' => count($subscription->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), - 'notes' => count($subscription->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), - 'tasks' => count($subscription->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), + 'events' => $objectCounts['events'], + 'notes' => $objectCounts['notes'], + 'tasks' => $objectCounts['tasks'], ]; } @@ -205,23 +200,23 @@ public function getUserCalendarDetails(Request $request, string $username, int $ $calendar_details = []; foreach ($allCalendars as $calendar) { if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { - $calendarComponents = explode(',', $calendar->getCalendar()->getComponents()); + $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($calendar->getCalendar()->getId()); $calendar_details = [ 'id' => $calendar->getId(), 'uri' => $calendar->getUri(), 'displayname' => $calendar->getDisplayName(), 'description' => $calendar->getDescription(), 'events' => [ - 'enabled' => in_array(Calendar::COMPONENT_EVENTS, $calendarComponents, true), - 'count' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_EVENTS === $obj->getComponentType())), + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS), + 'count' => $objectCounts['events'], ], 'notes' => [ - 'enabled' => in_array(Calendar::COMPONENT_NOTES, $calendarComponents, true), - 'count' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_NOTES === $obj->getComponentType())), + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES), + 'count' => $objectCounts['notes'], ], 'tasks' => [ - 'enabled' => in_array(Calendar::COMPONENT_TODOS, $calendarComponents, true), - 'count' => count($calendar->getCalendar()->getObjects()->filter(fn ($obj) => Calendar::COMPONENT_TODOS === $obj->getComponentType())), + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS), + 'count' => $objectCounts['tasks'], ], ]; } diff --git a/src/Entity/Calendar.php b/src/Entity/Calendar.php index 68382ae1..97838469 100644 --- a/src/Entity/Calendar.php +++ b/src/Entity/Calendar.php @@ -140,4 +140,16 @@ public function getInstances(): Collection { return $this->instances; } + + /** + * Check if this calendar supports a specific component type. + * + * @param string $componentType The component type to check + * + * @return bool True if the component is supported, false otherwise + */ + public function isComponentEnabled(string $componentType): bool + { + return in_array($componentType, explode(',', $this->components ?? ''), true); + } } diff --git a/src/Repository/CalendarInstanceRepository.php b/src/Repository/CalendarInstanceRepository.php index 27ddac21..27c2c6c2 100644 --- a/src/Repository/CalendarInstanceRepository.php +++ b/src/Repository/CalendarInstanceRepository.php @@ -73,4 +73,46 @@ public function hasDifferentOwner(int $calendarId, string $principalUri): bool ->getQuery() ->getSingleScalarResult() > 0; } + + /** + * Get counts of calendar objects by component type for a calendar instance. + * + * @param int $calendarId The ID of the calendar + * + * @return array An associative array with keys 'events', 'notes', 'tasks' containing their respective counts + */ + public function getObjectCountsByComponentType(int $calendarId): array + { + $objectRepository = $this->getEntityManager()->getRepository(\App\Entity\CalendarObject::class); + + // Instead of three separate queries, get all counts in a single query + $results = $objectRepository->createQueryBuilder('o') + ->select('o.componentType, COUNT(o.id) as count') + ->where('o.calendar = :calendarId') + ->setParameter('calendarId', $calendarId) + ->groupBy('o.componentType') + ->getQuery() + ->getResult(); + + $componentTypeMap = [ + \App\Entity\Calendar::COMPONENT_EVENTS => 'events', + \App\Entity\Calendar::COMPONENT_NOTES => 'notes', + \App\Entity\Calendar::COMPONENT_TODOS => 'tasks', + ]; + + $counts = [ + 'events' => 0, + 'notes' => 0, + 'tasks' => 0, + ]; + + // Map query results to the expected keys + foreach ($results as $result) { + if (isset($componentTypeMap[$result['componentType']])) { + $counts[$componentTypeMap[$result['componentType']]] = (int) $result['count']; + } + } + + return $counts; + } } From ea0818fb32860c4f87daf5cbf47f5d8762d70a2f Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 12:28:52 +0100 Subject: [PATCH 39/60] ApiController PHPUnit Tests - Part 1 --- tests/Functional/ApiControllerTest.php | 164 +++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/Functional/ApiControllerTest.php diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php new file mode 100644 index 00000000..d75f64c0 --- /dev/null +++ b/tests/Functional/ApiControllerTest.php @@ -0,0 +1,164 @@ +request('GET', '/api/v1/users', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + return $data['data'][0]['username']; + } + + /* + * Test the health endpoint + */ + public function testHealth(): void + { + $client = static::createClient(); + $client->request('GET', '/api/v1/health'); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('OK', $data['status']); + } + + /* + * Test the user list endpoint + */ + public function testUserList(): void { + $client = static::createClient(); + $client->request('GET', '/api/v1/users', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertNotEmpty($data); + + // Check if user is present in db + $this->assertArrayHasKey('id', $data['data'][0]); + $this->assertArrayHasKey('uri', $data['data'][0]); + $this->assertArrayHasKey('username', $data['data'][0]); + } + + /* + * Test the user details endpoint + */ + public function testUserDetails(): void { + // Create client once + $client = static::createClient(); + + // Get username from existing user lists + $username = $this->getUserUsername($client); + + // Check user details endpoint + $client->request('GET', '/api/v1/users/' . $username, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertNotEmpty($data); + + // Check if user details are correct + $this->assertArrayHasKey('displayname', $data['data']); + $this->assertArrayHasKey('email', $data['data']); + $this->assertStringEqualsStringIgnoringLineEndings($username, $data['data']['username']); + } + + /* + * Test the user calendars list endpoint + */ + public function testUserCalendarsList(): void { + $client = static::createClient(); + $username = $this->getUserUsername($client); + + $client->request('GET', '/api/v1/calendars/' . $username, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertNotEmpty($data); + + // Check if calendar list is correct + $this->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('user_calendars', $data['data']); + $this->assertArrayHasKey('shared_calendars', $data['data']); + $this->assertArrayHasKey('subscriptions', $data['data']); + } + + public function testUserCalendarDetails(): void { + $client = static::createClient(); + $username = $this->getUserUsername($client); + + // Get calendar list to retrieve calendar ID + $client->request('GET', '/api/v1/calendars/' . $username, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertNotEmpty($data); + + $calendar_id = $data['data']['user_calendars'][0]['id']; + + // Check calendar details endpoint + $client->request('GET', '/api/v1/calendars/' . $username . '/' . $calendar_id, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertNotEmpty($data); + + // Check if calendar details are correct + $this->assertArrayHasKey('id', $data['data']); + $this->assertArrayHasKey('uri', $data['data']); + $this->assertArrayHasKey('displayname', $data['data']); + $this->assertArrayHasKey('description', $data['data']); + + $this->assertArrayHasKey('events', $data['data']); + $this->assertIsArray($data['data']['events']); + $this->assertArrayHasKey('notes', $data['data']); + $this->assertIsArray($data['data']['notes']); + $this->assertArrayHasKey('tasks', $data['data']); + $this->assertIsArray($data['data']['tasks']); + } + + // TODO: TestCreateUser + // TODO: TestShareCalendarToNewUser + // TODO: TestShareCalendarList + // TODO: TestUnshareCalendarToNewUser + // TODO: TestCreateCalendarForUser + // TODO: TestRemoveCalendarForUser +} From 7d7467aca8e9346ac439727207f5ea38acb0b399 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 18:26:01 +0100 Subject: [PATCH 40/60] ApiController Fixes based on ApiControllerTests Failures - Part 1 --- src/Controller/Api/ApiController.php | 93 +++++++++++++++++++++------- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 6271f360..7bcbfe67 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -13,6 +13,7 @@ use Symfony\Component\Routing\Annotation\Route; #[Route('/api/v1', name: 'api_v1_')] +// TODO: Check in insomnia the fix for JSON body data parsing class ApiController extends AbstractController { /** @@ -239,6 +240,7 @@ public function getUserCalendarDetails(Request $request, string $username, int $ * * @return JsonResponse A JSON response indicating the success or failure of the operation */ + // TODO: Update docs #[Route('/calendars/{username}/create', name: 'calendar_create', methods: ['POST'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] public function createNewUserCalendar(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { @@ -246,14 +248,23 @@ public function createNewUserCalendar(Request $request, string $username, Manage return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } - $calendarName = $request->get('name'); - if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName)) { + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); + } + + $calendarName = $data['name'] ?? null; + if (empty($calendarName) || 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } - $calendarURI = strtolower(str_replace(' ', '_', $calendarName)); + $calendarURI = $data['uri'] ?? null; + if (empty($calendarURI) || 1 !== preg_match('/^[a-z0-9_-]{1,128}$/', $calendarURI)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Calendar URI', 'timestamp' => $this->getTimestamp()], 400); + } - $calendarDescription = $request->get('description', ''); - if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription)) { + $calendarDescription = $data['description'] ?? ''; + if (!empty($calendarDescription) && 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,256}$/', $calendarDescription)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); } @@ -263,13 +274,17 @@ public function createNewUserCalendar(Request $request, string $username, Manage $calendarInstance->setCalendar($calendar); $calendarComponents = []; - if ('true' === $request->get('events_support', 'true')) { + // Handle both boolean and string values + $eventsSupport = $data['events_support'] ?? true; + if ($eventsSupport === true || $eventsSupport === 'true') { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } - if ('true' === $request->get('notes_support', 'false')) { + $notesSupport = $data['notes_support'] ?? false; + if ($notesSupport === true || $notesSupport === 'true') { $calendarComponents[] = Calendar::COMPONENT_NOTES; } - if ('true' === $request->get('tasks_support', 'false')) { + $tasksSupport = $data['tasks_support'] ?? false; + if ($tasksSupport === true || $tasksSupport === 'true') { $calendarComponents[] = Calendar::COMPONENT_TODOS; } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); @@ -287,12 +302,26 @@ public function createNewUserCalendar(Request $request, string $username, Manage $response = [ 'status' => 'success', + 'data' => [ + 'calendar_id' => $calendarInstance->getId(), + 'calendar_uri' => $calendarInstance->getUri(), + ], 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); } + /** + * Edits an existing calendar for a specific user. + * + * @param Request $request The HTTP POST request + * @param string $username The username of the user whose calendar is to be edited + * @param int $calendar_id The ID of the calendar to be edited + * + * @return JsonResponse A JSON response indicating the success or failure of the operation + */ + // TODO: Update docs #[Route('/calendars/{username}/{calendar_id}/edit', name: 'calendar_edit', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function editUserCalendar(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { @@ -314,13 +343,19 @@ public function editUserCalendar(Request $request, string $username, int $calend return $this->json(['status' => 'error', 'message' => 'Calendar Instance Not Found', 'timestamp' => $this->getTimestamp()], 404); } - $calendarName = $request->get('name'); - if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,64}$/', $calendarName)) { + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); + } + + $calendarName = $data['name'] ?? null; + if (empty($calendarName) || 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } - $calendarDescription = $request->get('description', ''); - if (1 !== preg_match('/^[a-zA-Z0-9 _-]{0,256}$/', $calendarDescription)) { + $calendarDescription = $data['description'] ?? ''; + if (!empty($calendarDescription) && 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,256}$/', $calendarDescription)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); } @@ -328,14 +363,18 @@ public function editUserCalendar(Request $request, string $username, int $calend $calendarInstance->setDisplayName($calendarName); $calendarInstance->setDescription($calendarDescription); - $calendarComponents = explode(',', $calendarInstance->getCalendar()->getComponents()); - if ('true' === $request->get('events_support', 'true')) { + $calendarComponents = []; + // Handle both boolean and string values + $eventsSupport = $data['events_support'] ?? true; + if ($eventsSupport === true || $eventsSupport === 'true') { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } - if ('true' === $request->get('notes_support', 'false')) { + $notesSupport = $data['notes_support'] ?? false; + if ($notesSupport === true || $notesSupport === 'true') { $calendarComponents[] = Calendar::COMPONENT_NOTES; } - if ('true' === $request->get('tasks_support', 'false')) { + $tasksSupport = $data['tasks_support'] ?? false; + if ($tasksSupport === true || $tasksSupport === 'true') { $calendarComponents[] = Calendar::COMPONENT_TODOS; } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); @@ -420,9 +459,15 @@ public function setUserCalendarsShare(Request $request, string $username, int $c return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID and User ID', 'timestamp' => $this->getTimestamp()], 400); } - $userId = $request->get('user_id'); - $writeAccess = $request->get('write_access'); - if (!is_numeric($userId) || !in_array($writeAccess, ['true', 'false'], true)) { + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); + } + + $userId = $data['user_id'] ?? null; + $writeAccess = $data['write_access'] ?? null; + if (!is_numeric($userId) || !in_array($writeAccess, [true, false, 'true', 'false'], true)) { return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID/Write Access Value', 'timestamp' => $this->getTimestamp()], 400); } @@ -434,7 +479,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); - $accessLevel = ('true' === $writeAccess ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); + $accessLevel = ($writeAccess === true || $writeAccess === 'true' ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); $entityManager = $doctrine->getManager(); if ($existingSharedInstance) { @@ -481,7 +526,13 @@ public function removeUserCalendarsShare(Request $request, string $username, int return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); } - $userId = $request->get('user_id'); + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); + } + + $userId = $data['user_id'] ?? null; if (!is_numeric($userId)) { return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID', 'timestamp' => $this->getTimestamp()], 400); } From b24ef150ca8ab490d37a8846cb294efda0f6f505 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 18:26:13 +0100 Subject: [PATCH 41/60] ApiController PHPUnit Tests - Part 2 --- tests/Functional/ApiControllerTest.php | 233 +++++++++++++++++++++---- 1 file changed, 203 insertions(+), 30 deletions(-) diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php index d75f64c0..debd0c4f 100644 --- a/tests/Functional/ApiControllerTest.php +++ b/tests/Functional/ApiControllerTest.php @@ -6,19 +6,61 @@ class ApiControllerTest extends WebTestCase { - private function getUserUsername($client): string { + /* + * Helper function to get an existing username from the user list + * + * @param mixed $client + * + * @return string Username + */ + private function getUserUsername($client): string + { $client->request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); - + $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); + + $this->assertArrayHasKey('data', $data);; + $this->assertIsArray($data['data']); + $this->assertStringContainsString('test_user', $data['data'][0]['username']); + return $data['data'][0]['username']; } + /* + * Helper function to get an existing calendar ID from the user calendar list + * + * @param mixed $client + * @param string $username + * @param bool $default Whether to get the default calendar (true) or the second calendar (false) + * + * @return int Calendar ID + */ + private function getCalendarId($client, string $username, bool $default = true): int + { + $client->request('GET', '/api/v1/calendars/'.$username, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertNotEmpty($data); + + if ($default) { + return $data['data']['user_calendars'][0]['id']; + } else { + return $data['data']['user_calendars'][1]['id']; + } + } + /* * Test the health endpoint */ @@ -26,10 +68,10 @@ public function testHealth(): void { $client = static::createClient(); $client->request('GET', '/api/v1/health'); - + $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); - + $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('OK', $data['status']); } @@ -37,40 +79,44 @@ public function testHealth(): void /* * Test the user list endpoint */ - public function testUserList(): void { + public function testUserList(): void + { $client = static::createClient(); $client->request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); - + $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); - + $data = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($data); $this->assertNotEmpty($data); - + // Check if user is present in db $this->assertArrayHasKey('id', $data['data'][0]); $this->assertArrayHasKey('uri', $data['data'][0]); + $this->assertStringContainsString('principals/test_user', $data['data'][0]['uri']); $this->assertArrayHasKey('username', $data['data'][0]); + $this->assertStringContainsString('test_user', $data['data'][0]['username']); } /* * Test the user details endpoint */ - public function testUserDetails(): void { + public function testUserDetails(): void + { // Create client once $client = static::createClient(); - + // Get username from existing user lists $username = $this->getUserUsername($client); - + // Check user details endpoint - $client->request('GET', '/api/v1/users/' . $username, [], [], [ + $client->request('GET', '/api/v1/users/'.$username, [], [], [ 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); @@ -81,20 +127,23 @@ public function testUserDetails(): void { // Check if user details are correct $this->assertArrayHasKey('displayname', $data['data']); + $this->assertStringContainsString('Test User', $data['data']['displayname']); $this->assertArrayHasKey('email', $data['data']); + $this->assertStringContainsString('test@test.com', $data['data']['email']); $this->assertStringEqualsStringIgnoringLineEndings($username, $data['data']['username']); } /* * Test the user calendars list endpoint */ - public function testUserCalendarsList(): void { + public function testUserCalendarsList(): void + { $client = static::createClient(); $username = $this->getUserUsername($client); - $client->request('GET', '/api/v1/calendars/' . $username, [], [], [ + $client->request('GET', '/api/v1/calendars/'.$username, [], [], [ 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); @@ -107,18 +156,24 @@ public function testUserCalendarsList(): void { $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('user_calendars', $data['data']); + $this->assertStringContainsString('default', $data['data']['user_calendars'][0]['uri']); + $this->assertStringContainsString('default.calendar.title', $data['data']['user_calendars'][0]['displayname']); $this->assertArrayHasKey('shared_calendars', $data['data']); $this->assertArrayHasKey('subscriptions', $data['data']); } - public function testUserCalendarDetails(): void { + /* + * Test the user calendar details endpoint + */ + public function testUserCalendarDetails(): void + { $client = static::createClient(); $username = $this->getUserUsername($client); // Get calendar list to retrieve calendar ID - $client->request('GET', '/api/v1/calendars/' . $username, [], [], [ + $client->request('GET', '/api/v1/calendars/'.$username, [], [], [ 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); @@ -128,11 +183,11 @@ public function testUserCalendarDetails(): void { $this->assertNotEmpty($data); $calendar_id = $data['data']['user_calendars'][0]['id']; - + // Check calendar details endpoint - $client->request('GET', '/api/v1/calendars/' . $username . '/' . $calendar_id, [], [], [ + $client->request('GET', '/api/v1/calendars/'.$username.'/'.$calendar_id, [], [], [ 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'] + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); @@ -141,12 +196,17 @@ public function testUserCalendarDetails(): void { $this->assertIsArray($data); $this->assertNotEmpty($data); + // Check if calendar details are correct $this->assertArrayHasKey('id', $data['data']); $this->assertArrayHasKey('uri', $data['data']); + $this->assertStringContainsString('default', $data['data']['uri']); $this->assertArrayHasKey('displayname', $data['data']); + $this->assertStringContainsString('default.calendar.title', $data['data']['displayname']); + $this->assertArrayHasKey('description', $data['data']); - + $this->assertStringContainsString('default.calendar.description', $data['data']['description']); + $this->assertArrayHasKey('events', $data['data']); $this->assertIsArray($data['data']['events']); $this->assertArrayHasKey('notes', $data['data']); @@ -155,10 +215,123 @@ public function testUserCalendarDetails(): void { $this->assertIsArray($data['data']['tasks']); } - // TODO: TestCreateUser - // TODO: TestShareCalendarToNewUser + /* + * Test creating a new user calendar + */ + public function testCreateUserCalendar(): void { + $client = static::createClient(); + $username = $this->getUserUsername($client); + + // Create user API request with JSON body + $payload = [ + 'uri' => 'api_calendar', + 'name' => 'api.calendar.title', + 'description' => 'api.calendar.description', + 'events_support' => true, + 'tasks_support' => true, + 'notes_support' => false, + ]; + + $client->request('POST', '/api/v1/calendars/'.$username.'/create', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + + $this->assertIsArray($data); + $this->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('data', $data); + $this->assertMatchesRegularExpression('/^\d+$/', $data['data']['calendar_id']); + $this->assertStringContainsString('api_calendar', $data['data']['calendar_uri']); + + // Check if calendar is created + $calendarId = $data['data']['calendar_id']; + $client->request('GET', '/api/v1/calendars/'.$username.'/'.$calendarId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertNotEmpty($data); + + $this->assertArrayHasKey('events', $data['data']); + $this->assertIsBool($data['data']['events']['enabled']); + $this->assertTrue($data['data']['events']['enabled']); + $this->assertArrayHasKey('tasks', $data['data']); + $this->assertIsBool($data['data']['tasks']['enabled']); + $this->assertTrue($data['data']['tasks']['enabled']); + $this->assertArrayHasKey('notes', $data['data']); + $this->assertIsBool($data['data']['notes']['enabled']); + $this->assertFalse($data['data']['notes']['enabled']); + } + + /* + * Test editing a user calendar + */ + public function testEditUserCalendar(): void { + $client = static::createClient(); + $username = $this->getUserUsername($client); + $calendar_id = $this->getCalendarId($client, $username, true); + + // Edit user default calendar + $payload = [ + 'name' => 'api.calendar.edited.title', + 'description' => 'api.calendar.edited.description', + 'events_support' => true, + 'tasks_support' => true, + 'notes_support' => true, + ]; + $client->request('POST', '/api/v1/calendars/'.$username.'/'.$calendar_id.'/edit', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + + // Check if edits were applied + $client->request('GET', '/api/v1/calendars/'.$username.'/'.$calendar_id, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertArrayHasKey('data', $data); + $this->assertIsArray($data['data']); + $this->assertStringContainsString($payload['name'], $data['data']['displayname']); + $this->assertStringContainsString($payload['description'], $data['data']['description']); + + $this->assertArrayHasKey('events', $data['data']); + $this->assertIsBool($data['data']['events']['enabled']); + $this->assertTrue($data['data']['events']['enabled']); + $this->assertArrayHasKey('tasks', $data['data']); + $this->assertIsBool($data['data']['tasks']['enabled']); + $this->assertTrue($data['data']['tasks']['enabled']); + $this->assertArrayHasKey('notes', $data['data']); + $this->assertIsBool($data['data']['notes']['enabled']); + $this->assertTrue($data['data']['notes']['enabled']); + } + + + // TODO: TestShareCalendarToUser // TODO: TestShareCalendarList - // TODO: TestUnshareCalendarToNewUser - // TODO: TestCreateCalendarForUser - // TODO: TestRemoveCalendarForUser + // TODO: TestUnshareCalendarToUser + // TODO: TestEditCalendarForUser } From a90ba7f6c3ea3efc923a9b7331fc5bca25502f3f Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 18:26:31 +0100 Subject: [PATCH 42/60] Code Linting --- src/Controller/Api/ApiController.php | 22 +++++++++++----------- tests/Functional/ApiControllerTest.php | 26 +++++++++++++------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 7bcbfe67..6b4bbbfe 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -250,7 +250,7 @@ public function createNewUserCalendar(Request $request, string $username, Manage // Parse JSON body $data = json_decode($request->getContent(), true); - if (json_last_error() !== JSON_ERROR_NONE) { + if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } @@ -276,15 +276,15 @@ public function createNewUserCalendar(Request $request, string $username, Manage $calendarComponents = []; // Handle both boolean and string values $eventsSupport = $data['events_support'] ?? true; - if ($eventsSupport === true || $eventsSupport === 'true') { + if (true === $eventsSupport || 'true' === $eventsSupport) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } $notesSupport = $data['notes_support'] ?? false; - if ($notesSupport === true || $notesSupport === 'true') { + if (true === $notesSupport || 'true' === $notesSupport) { $calendarComponents[] = Calendar::COMPONENT_NOTES; } $tasksSupport = $data['tasks_support'] ?? false; - if ($tasksSupport === true || $tasksSupport === 'true') { + if (true === $tasksSupport || 'true' === $tasksSupport) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); @@ -345,7 +345,7 @@ public function editUserCalendar(Request $request, string $username, int $calend // Parse JSON body $data = json_decode($request->getContent(), true); - if (json_last_error() !== JSON_ERROR_NONE) { + if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } @@ -366,15 +366,15 @@ public function editUserCalendar(Request $request, string $username, int $calend $calendarComponents = []; // Handle both boolean and string values $eventsSupport = $data['events_support'] ?? true; - if ($eventsSupport === true || $eventsSupport === 'true') { + if (true === $eventsSupport || 'true' === $eventsSupport) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } $notesSupport = $data['notes_support'] ?? false; - if ($notesSupport === true || $notesSupport === 'true') { + if (true === $notesSupport || 'true' === $notesSupport) { $calendarComponents[] = Calendar::COMPONENT_NOTES; } $tasksSupport = $data['tasks_support'] ?? false; - if ($tasksSupport === true || $tasksSupport === 'true') { + if (true === $tasksSupport || 'true' === $tasksSupport) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); @@ -461,7 +461,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c // Parse JSON body $data = json_decode($request->getContent(), true); - if (json_last_error() !== JSON_ERROR_NONE) { + if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } @@ -479,7 +479,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); - $accessLevel = ($writeAccess === true || $writeAccess === 'true' ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); + $accessLevel = (true === $writeAccess || 'true' === $writeAccess ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); $entityManager = $doctrine->getManager(); if ($existingSharedInstance) { @@ -528,7 +528,7 @@ public function removeUserCalendarsShare(Request $request, string $username, int // Parse JSON body $data = json_decode($request->getContent(), true); - if (json_last_error() !== JSON_ERROR_NONE) { + if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php index debd0c4f..f76afa86 100644 --- a/tests/Functional/ApiControllerTest.php +++ b/tests/Functional/ApiControllerTest.php @@ -8,9 +8,9 @@ class ApiControllerTest extends WebTestCase { /* * Helper function to get an existing username from the user list - * + * * @param mixed $client - * + * * @return string Username */ private function getUserUsername($client): string @@ -22,10 +22,10 @@ private function getUserUsername($client): string $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); - + $data = json_decode($client->getResponse()->getContent(), true); - $this->assertArrayHasKey('data', $data);; + $this->assertArrayHasKey('data', $data); $this->assertIsArray($data['data']); $this->assertStringContainsString('test_user', $data['data'][0]['username']); @@ -34,11 +34,11 @@ private function getUserUsername($client): string /* * Helper function to get an existing calendar ID from the user calendar list - * + * * @param mixed $client * @param string $username * @param bool $default Whether to get the default calendar (true) or the second calendar (false) - * + * * @return int Calendar ID */ private function getCalendarId($client, string $username, bool $default = true): int @@ -196,7 +196,6 @@ public function testUserCalendarDetails(): void $this->assertIsArray($data); $this->assertNotEmpty($data); - // Check if calendar details are correct $this->assertArrayHasKey('id', $data['data']); $this->assertArrayHasKey('uri', $data['data']); @@ -218,7 +217,8 @@ public function testUserCalendarDetails(): void /* * Test creating a new user calendar */ - public function testCreateUserCalendar(): void { + public function testCreateUserCalendar(): void + { $client = static::createClient(); $username = $this->getUserUsername($client); @@ -242,14 +242,14 @@ public function testCreateUserCalendar(): void { $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - + $this->assertIsArray($data); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); $this->assertMatchesRegularExpression('/^\d+$/', $data['data']['calendar_id']); $this->assertStringContainsString('api_calendar', $data['data']['calendar_uri']); - + // Check if calendar is created $calendarId = $data['data']['calendar_id']; $client->request('GET', '/api/v1/calendars/'.$username.'/'.$calendarId, [], [], [ @@ -277,7 +277,8 @@ public function testCreateUserCalendar(): void { /* * Test editing a user calendar */ - public function testEditUserCalendar(): void { + public function testEditUserCalendar(): void + { $client = static::createClient(); $username = $this->getUserUsername($client); $calendar_id = $this->getCalendarId($client, $username, true); @@ -317,7 +318,7 @@ public function testEditUserCalendar(): void { $this->assertIsArray($data['data']); $this->assertStringContainsString($payload['name'], $data['data']['displayname']); $this->assertStringContainsString($payload['description'], $data['data']['description']); - + $this->assertArrayHasKey('events', $data['data']); $this->assertIsBool($data['data']['events']['enabled']); $this->assertTrue($data['data']['events']['enabled']); @@ -329,7 +330,6 @@ public function testEditUserCalendar(): void { $this->assertTrue($data['data']['notes']['enabled']); } - // TODO: TestShareCalendarToUser // TODO: TestShareCalendarList // TODO: TestUnshareCalendarToUser From cce1f43bb378d33da46cbb160df25ecc95b18b83 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 18:36:51 +0100 Subject: [PATCH 43/60] ApiController PHPUnit Tests - Part 3 Assertion Cleanup --- tests/Functional/ApiControllerTest.php | 33 ++++---------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php index f76afa86..c35c7f20 100644 --- a/tests/Functional/ApiControllerTest.php +++ b/tests/Functional/ApiControllerTest.php @@ -26,7 +26,6 @@ private function getUserUsername($client): string $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('data', $data); - $this->assertIsArray($data['data']); $this->assertStringContainsString('test_user', $data['data'][0]['username']); return $data['data'][0]['username']; @@ -51,12 +50,14 @@ private function getCalendarId($client, string $username, bool $default = true): $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); - $this->assertNotEmpty($data); if ($default) { + $this->assertMatchesRegularExpression('/^\d+$/', $data['data']['user_calendars'][0]['id']); + return $data['data']['user_calendars'][0]['id']; } else { + $this->assertMatchesRegularExpression('/^\d+$/', $data['data']['user_calendars'][1]['id']); + return $data['data']['user_calendars'][1]['id']; } } @@ -91,8 +92,6 @@ public function testUserList(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); - $this->assertNotEmpty($data); // Check if user is present in db $this->assertArrayHasKey('id', $data['data'][0]); @@ -122,8 +121,6 @@ public function testUserDetails(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); - $this->assertNotEmpty($data); // Check if user details are correct $this->assertArrayHasKey('displayname', $data['data']); @@ -149,8 +146,6 @@ public function testUserCalendarsList(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); - $this->assertNotEmpty($data); // Check if calendar list is correct $this->assertArrayHasKey('status', $data); @@ -179,9 +174,6 @@ public function testUserCalendarDetails(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); - $this->assertNotEmpty($data); - $calendar_id = $data['data']['user_calendars'][0]['id']; // Check calendar details endpoint @@ -193,8 +185,6 @@ public function testUserCalendarDetails(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); - $this->assertNotEmpty($data); // Check if calendar details are correct $this->assertArrayHasKey('id', $data['data']); @@ -207,11 +197,8 @@ public function testUserCalendarDetails(): void $this->assertStringContainsString('default.calendar.description', $data['data']['description']); $this->assertArrayHasKey('events', $data['data']); - $this->assertIsArray($data['data']['events']); $this->assertArrayHasKey('notes', $data['data']); - $this->assertIsArray($data['data']['notes']); $this->assertArrayHasKey('tasks', $data['data']); - $this->assertIsArray($data['data']['tasks']); } /* @@ -243,7 +230,6 @@ public function testCreateUserCalendar(): void $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); @@ -260,17 +246,12 @@ public function testCreateUserCalendar(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); - $this->assertNotEmpty($data); $this->assertArrayHasKey('events', $data['data']); - $this->assertIsBool($data['data']['events']['enabled']); $this->assertTrue($data['data']['events']['enabled']); $this->assertArrayHasKey('tasks', $data['data']); - $this->assertIsBool($data['data']['tasks']['enabled']); $this->assertTrue($data['data']['tasks']['enabled']); $this->assertArrayHasKey('notes', $data['data']); - $this->assertIsBool($data['data']['notes']['enabled']); $this->assertFalse($data['data']['notes']['enabled']); } @@ -313,25 +294,19 @@ public function testEditUserCalendar(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); $this->assertArrayHasKey('data', $data); - $this->assertIsArray($data['data']); $this->assertStringContainsString($payload['name'], $data['data']['displayname']); $this->assertStringContainsString($payload['description'], $data['data']['description']); $this->assertArrayHasKey('events', $data['data']); - $this->assertIsBool($data['data']['events']['enabled']); $this->assertTrue($data['data']['events']['enabled']); $this->assertArrayHasKey('tasks', $data['data']); - $this->assertIsBool($data['data']['tasks']['enabled']); $this->assertTrue($data['data']['tasks']['enabled']); $this->assertArrayHasKey('notes', $data['data']); - $this->assertIsBool($data['data']['notes']['enabled']); $this->assertTrue($data['data']['notes']['enabled']); } // TODO: TestShareCalendarToUser // TODO: TestShareCalendarList // TODO: TestUnshareCalendarToUser - // TODO: TestEditCalendarForUser } From 60c9bb1d53348102dbbc30579754f7affe3a4e8a Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 18:39:46 +0100 Subject: [PATCH 44/60] Docs Update --- docs/api/v1/calendars/create.md | 34 +++++++++++++++++++++++++++ docs/api/v1/calendars/edit.md | 14 +++++++++++ docs/api/v1/calendars/share_add.md | 16 ++++++++++++- docs/api/v1/calendars/share_remove.md | 16 ++++++++++++- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/docs/api/v1/calendars/create.md b/docs/api/v1/calendars/create.md index 0c2ce075..960cd651 100644 --- a/docs/api/v1/calendars/create.md +++ b/docs/api/v1/calendars/create.md @@ -19,6 +19,7 @@ Creates a new calendar for a specific user. ```json { "name": "[string: calendar name, alphanumeric, spaces, underscores and hyphens, max 64 chars]", + "uri": "[string: calendar URI, lowercase alphanumeric, underscores and hyphens, max 128 chars]", "description": "[string: calendar description, alphanumeric, spaces, underscores and hyphens, max 256 chars, optional]", "events_support": "[string: 'true' or 'false', default 'true', optional]", "notes_support": "[string: 'true' or 'false', default 'false', optional]", @@ -37,6 +38,7 @@ Creates a new calendar for a specific user. ```json { "name": "Work Calendar", + "uri": "work-calendar", "description": "Calendar for work events", "events_support": "true", "notes_support": "false", @@ -53,6 +55,10 @@ Creates a new calendar for a specific user. ```json { "status": "success", + "data": { + "calendar_id": 5, + "calendar_uri": "work-calendar" + }, "timestamp": "2026-01-23T15:01:33+01:00" } ``` @@ -95,6 +101,20 @@ or } ``` +**Condition** : If request body contains invalid JSON. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid JSON", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + **Condition** : If 'name' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` @@ -109,6 +129,20 @@ or } ``` +**Condition** : If 'uri' parameter is invalid (not matching the regex or exceeds length). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar URI", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + **Condition** : If 'description' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` diff --git a/docs/api/v1/calendars/edit.md b/docs/api/v1/calendars/edit.md index 4388b97b..1f77a46e 100644 --- a/docs/api/v1/calendars/edit.md +++ b/docs/api/v1/calendars/edit.md @@ -96,6 +96,20 @@ or } ``` +**Condition** : If request body contains invalid JSON. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid JSON", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + **Condition** : If ':calendar_id' is not owned by the specified ':username'. **Code** : `400 BAD REQUEST` diff --git a/docs/api/v1/calendars/share_add.md b/docs/api/v1/calendars/share_add.md index 988b083b..6373e1e2 100644 --- a/docs/api/v1/calendars/share_add.md +++ b/docs/api/v1/calendars/share_add.md @@ -18,7 +18,7 @@ Shares (or updates write access) a calendar owned by the specified user to anoth ** Request Body constraints** ```json { - "user_id": "[numeric id of the user to remove access]", + "user_id": "[numeric id of the user to add/update access]", "write_access": "[boolean: true to grant write access, false for read-only]" } ``` @@ -89,6 +89,20 @@ or } ``` +**Condition** : If request body contains invalid JSON. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid JSON", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + **Condition** : If ':calendar_id' is not owned by the specified ':username'. **Code** : `400 BAD REQUEST` diff --git a/docs/api/v1/calendars/share_remove.md b/docs/api/v1/calendars/share_remove.md index 5cc41870..7c90032b 100644 --- a/docs/api/v1/calendars/share_remove.md +++ b/docs/api/v1/calendars/share_remove.md @@ -32,7 +32,7 @@ Removes access to a specific shared calendar for a specific user. ```json { - "user_id": "3", + "user_id": "3" } ``` @@ -87,6 +87,20 @@ or } ``` +**Condition** : If request body contains invalid JSON. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid JSON", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + **Condition** : If ':calendar_id' is not owned by the specified ':username'. **Code** : `400 BAD REQUEST` From 2f6145e95b5d22ee2c6ede1bcc4888f1fbac0114 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 21:34:00 +0100 Subject: [PATCH 45/60] Add 2nd Test User for API testing --- src/DataFixtures/AppFixtures.php | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 5b84dccc..c105fef9 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -14,6 +14,7 @@ class AppFixtures extends Fixture { public function load(ObjectManager $manager): void { + // Test User 1 $hash = password_hash('password', PASSWORD_DEFAULT); $user = (new User()) ->setUsername('test_user') @@ -56,5 +57,50 @@ public function load(ObjectManager $manager): void $manager->persist($addressbook); $manager->flush(); + + // Test User 2 - For API testing + $hash = password_hash('password2', PASSWORD_DEFAULT); + $user = (new User()) + ->setUsername('test_user2') + ->setPassword($hash); + $manager->persist($user); + + $principal = (new Principal()) + ->setUri(Principal::PREFIX.$user->getUsername()) + ->setEmail('test2@test.com') + ->setDisplayName('Test User 2') + ->setIsAdmin(true); + $manager->persist($principal); + + // Create all the default calendar / addressbook + $calendarInstance = new CalendarInstance(); + $calendar = new Calendar(); + $calendarInstance->setPrincipalUri(Principal::PREFIX.$user->getUsername()) + ->setUri('default') + ->setDisplayName('default.calendar.title') + ->setDescription('default.calendar.description') + ->setCalendar($calendar); + $manager->persist($calendarInstance); + + // Enable delegation by default + $principalProxyRead = new Principal(); + $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) + ->setIsMain(false); + $manager->persist($principalProxyRead); + + $principalProxyWrite = new Principal(); + $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) + ->setIsMain(false); + $manager->persist($principalProxyWrite); + + $addressbook = new AddressBook(); + $addressbook->setPrincipalUri(Principal::PREFIX.$user->getUsername()) + ->setUri('default') + ->setDisplayName('default.addressbook.title') + ->setDescription('default.addressbook.description'); + $manager->persist($addressbook); + + $manager->flush(); + } } From 584583c17c092dbcf251f0f64454ebec262fa8f7 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 22:07:44 +0100 Subject: [PATCH 46/60] Add check for adding calendar with specific URI --- src/Controller/Api/ApiController.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 6b4bbbfe..62859874 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -13,7 +13,6 @@ use Symfony\Component\Routing\Annotation\Route; #[Route('/api/v1', name: 'api_v1_')] -// TODO: Check in insomnia the fix for JSON body data parsing class ApiController extends AbstractController { /** @@ -240,7 +239,6 @@ public function getUserCalendarDetails(Request $request, string $username, int $ * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - // TODO: Update docs #[Route('/calendars/{username}/create', name: 'calendar_create', methods: ['POST'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] public function createNewUserCalendar(Request $request, string $username, ManagerRegistry $doctrine): JsonResponse { @@ -263,6 +261,14 @@ public function createNewUserCalendar(Request $request, string $username, Manage return $this->json(['status' => 'error', 'message' => 'Invalid Calendar URI', 'timestamp' => $this->getTimestamp()], 400); } + $uriCheck = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ + 'principalUri' => Principal::PREFIX.$username, + 'uri' => $calendarURI, + ]); + if ($uriCheck) { + return $this->json(['status' => 'error', 'message' => 'Calendar URI Already Exists', 'timestamp' => $this->getTimestamp()], 400); + } + $calendarDescription = $data['description'] ?? ''; if (!empty($calendarDescription) && 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,256}$/', $calendarDescription)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); @@ -321,7 +327,6 @@ public function createNewUserCalendar(Request $request, string $username, Manage * * @return JsonResponse A JSON response indicating the success or failure of the operation */ - // TODO: Update docs #[Route('/calendars/{username}/{calendar_id}/edit', name: 'calendar_edit', methods: ['POST'], requirements: ['calendar_id' => "\d+", 'username' => '[a-zA-Z0-9_-]+'])] public function editUserCalendar(Request $request, string $username, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { From 1570725dbc1a4621e46b60631edfcfe38bb66297 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 22:08:05 +0100 Subject: [PATCH 47/60] Update calendar create docs --- docs/api/v1/calendars/create.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/api/v1/calendars/create.md b/docs/api/v1/calendars/create.md index 960cd651..6f7a9188 100644 --- a/docs/api/v1/calendars/create.md +++ b/docs/api/v1/calendars/create.md @@ -143,6 +143,20 @@ or } ``` +**Condition** : If calendar with specified URI already exists for the user. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Calendar URI Already Exists", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + **Condition** : If 'description' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` From b082d9de648631a83a898d16e118c978422d1d37 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 22:08:26 +0100 Subject: [PATCH 48/60] Added 2nd user for API tests --- src/DataFixtures/AppFixtures.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index c105fef9..53a1f454 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -69,7 +69,7 @@ public function load(ObjectManager $manager): void ->setUri(Principal::PREFIX.$user->getUsername()) ->setEmail('test2@test.com') ->setDisplayName('Test User 2') - ->setIsAdmin(true); + ->setIsAdmin(false); $manager->persist($principal); // Create all the default calendar / addressbook @@ -77,8 +77,8 @@ public function load(ObjectManager $manager): void $calendar = new Calendar(); $calendarInstance->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') - ->setDisplayName('default.calendar.title') - ->setDescription('default.calendar.description') + ->setDisplayName('default.calendar.title2') + ->setDescription('default.calendar.description2') ->setCalendar($calendar); $manager->persist($calendarInstance); @@ -96,11 +96,10 @@ public function load(ObjectManager $manager): void $addressbook = new AddressBook(); $addressbook->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') - ->setDisplayName('default.addressbook.title') - ->setDescription('default.addressbook.description'); + ->setDisplayName('default.addressbook.title2') + ->setDescription('default.addressbook.description2'); $manager->persist($addressbook); $manager->flush(); - } } From a72674f7166048fe07b9a687b220ff9adcbaefc8 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 22:18:05 +0100 Subject: [PATCH 49/60] Adjusted API Auth Failure return --- src/Security/ApiKeyAuthenticator.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index 1a6ede40..d0375395 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -61,6 +61,7 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { $data = [ + 'status' => 'error', 'message' => $exception->getMessage(), 'timestamp' => date('c'), ]; From aadea9c7a24c7949740d78268ceb35e85f0bd0e3 Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 4 Feb 2026 22:18:35 +0100 Subject: [PATCH 50/60] ApiController PHPUnit Tests - Part 4 --- tests/Functional/ApiControllerTest.php | 206 +++++++++++++++++++++++-- 1 file changed, 190 insertions(+), 16 deletions(-) diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php index c35c7f20..c1fec1f0 100644 --- a/tests/Functional/ApiControllerTest.php +++ b/tests/Functional/ApiControllerTest.php @@ -9,11 +9,12 @@ class ApiControllerTest extends WebTestCase /* * Helper function to get an existing username from the user list * + * @param int $index Index of the user in the list (0 - first user, 1 - second user) * @param mixed $client * * @return string Username */ - private function getUserUsername($client): string + private function getUserUsername($client, int $index): string { $client->request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', @@ -26,9 +27,35 @@ private function getUserUsername($client): string $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('data', $data); - $this->assertStringContainsString('test_user', $data['data'][0]['username']); + $this->assertStringContainsString('test_user', $data['data'][$index]['username']); + + return $data['data'][$index]['username']; + } + + /* + * Helper function to get an existing user ID from the user list + * + * @param mixed $client + * @param int $index Index of the user in the list (0 - first user, 1 - second user) + * + * @return int User ID + */ + private function getUserId($client, int $index): int + { + $client->request('GET', '/api/v1/users', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); - return $data['data'][0]['username']; + $this->assertArrayHasKey('data', $data); + $this->assertStringContainsString('test_user', $data['data'][$index]['username']); + + return $data['data'][$index]['id']; } /* @@ -77,6 +104,41 @@ public function testHealth(): void $this->assertEquals('OK', $data['status']); } + /* + * Test the API endpoint with invalid token + */ + public function testApiInvalidToken(): void + { + $client = static::createClient(); + $client->request('GET', '/api/v1/users', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => 'invalid_token', + ]); + $this->assertResponseStatusCodeSame(401); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertEquals('Invalid X-Davis-API-Token header', $data['message']); + } + + /* + * Test the API endpoint with missing token + */ + public function testApiMissingToken(): void + { + $client = static::createClient(); + $client->request('GET', '/api/v1/users', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + ]); + $this->assertResponseStatusCodeSame(401); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertEquals('Missing X-Davis-API-Token header', $data['message']); + } + /* * Test the user list endpoint */ @@ -93,12 +155,19 @@ public function testUserList(): void $data = json_decode($client->getResponse()->getContent(), true); - // Check if user is present in db + // Check if user1 is present in db $this->assertArrayHasKey('id', $data['data'][0]); $this->assertArrayHasKey('uri', $data['data'][0]); $this->assertStringContainsString('principals/test_user', $data['data'][0]['uri']); $this->assertArrayHasKey('username', $data['data'][0]); $this->assertStringContainsString('test_user', $data['data'][0]['username']); + + // Check if user2 is present in db + $this->assertArrayHasKey('id', $data['data'][1]); + $this->assertArrayHasKey('uri', $data['data'][1]); + $this->assertStringContainsString('principals/test_user2', $data['data'][1]['uri']); + $this->assertArrayHasKey('username', $data['data'][1]); + $this->assertStringContainsString('test_user2', $data['data'][1]['username']); } /* @@ -110,7 +179,7 @@ public function testUserDetails(): void $client = static::createClient(); // Get username from existing user lists - $username = $this->getUserUsername($client); + $username = $this->getUserUsername($client, 0); // Check user details endpoint $client->request('GET', '/api/v1/users/'.$username, [], [], [ @@ -136,7 +205,7 @@ public function testUserDetails(): void public function testUserCalendarsList(): void { $client = static::createClient(); - $username = $this->getUserUsername($client); + $username = $this->getUserUsername($client, 0); $client->request('GET', '/api/v1/calendars/'.$username, [], [], [ 'HTTP_ACCEPT' => 'application/json', @@ -163,7 +232,7 @@ public function testUserCalendarsList(): void public function testUserCalendarDetails(): void { $client = static::createClient(); - $username = $this->getUserUsername($client); + $username = $this->getUserUsername($client, 0); // Get calendar list to retrieve calendar ID $client->request('GET', '/api/v1/calendars/'.$username, [], [], [ @@ -207,9 +276,9 @@ public function testUserCalendarDetails(): void public function testCreateUserCalendar(): void { $client = static::createClient(); - $username = $this->getUserUsername($client); + $username = $this->getUserUsername($client, 0); - // Create user API request with JSON body + // Create calendar API request with JSON body $payload = [ 'uri' => 'api_calendar', 'name' => 'api.calendar.title', @@ -261,8 +330,8 @@ public function testCreateUserCalendar(): void public function testEditUserCalendar(): void { $client = static::createClient(); - $username = $this->getUserUsername($client); - $calendar_id = $this->getCalendarId($client, $username, true); + $username = $this->getUserUsername($client, 0); + $calendarId = $this->getCalendarId($client, $username, true); // Edit user default calendar $payload = [ @@ -272,7 +341,7 @@ public function testEditUserCalendar(): void 'tasks_support' => true, 'notes_support' => true, ]; - $client->request('POST', '/api/v1/calendars/'.$username.'/'.$calendar_id.'/edit', [], [], [ + $client->request('POST', '/api/v1/calendars/'.$username.'/'.$calendarId.'/edit', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', @@ -286,7 +355,7 @@ public function testEditUserCalendar(): void $this->assertEquals('success', $data['status']); // Check if edits were applied - $client->request('GET', '/api/v1/calendars/'.$username.'/'.$calendar_id, [], [], [ + $client->request('GET', '/api/v1/calendars/'.$username.'/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); @@ -306,7 +375,112 @@ public function testEditUserCalendar(): void $this->assertTrue($data['data']['notes']['enabled']); } - // TODO: TestShareCalendarToUser - // TODO: TestShareCalendarList - // TODO: TestUnshareCalendarToUser + /* + * Test getting shares for a user calendar (should be empty initially) + */ + public function testGetUserCalendarSharesEmpty(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + $calendarId = $this->getCalendarId($client, $username, true); + + // Get shares for user default calendar + $client->request('GET', '/api/v1/calendars/'.$username.'/shares/'.$calendarId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('data', $data); + $this->assertEmpty($data['data']); + } + + // TODO: FIX WHY IT DOES NOT PERSISTS BETWEEN REQUESTS + public function testShareUserCalendar(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + $shareeUserId = $this->getUserId($client, 1); + $calendarId = $this->getCalendarId($client, $username, true); + + // Share user default calendar to test_user2 + $payload = [ + 'user_id' => $shareeUserId, + 'write_access' => false, + ]; + $client->request('POST', '/api/v1/calendars/'.$username.'/share/'.$calendarId.'/add', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + var_dump($data); + + // Check if share was applied + $client->request('GET', '/api/v1/calendars/'.$username.'/shares/'.$calendarId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data2 = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data2); + $this->assertArrayHasKey('status', $data2); + $this->assertEquals('success', $data2['status']); + $this->assertArrayHasKey('data', $data2); + var_dump($data2); + } + + /* + * Test removing shared access to user calendar + */ + public function testUnshareUserCalendar(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + $shareeUserId = $this->getUserId($client, 1); + $calendarId = $this->getCalendarId($client, $username, true); + + // Unshare user default calendar from test_user2 + $payload = [ + 'user_id' => $shareeUserId, + ]; + $client->request('POST', '/api/v1/calendars/'.$username.'/share/'.$calendarId.'/remove', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertEquals('success', $data['status']); + + // Check if unshare was applied + $client->request('GET', '/api/v1/calendars/'.$username.'/shares/'.$calendarId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('data', $data); + $this->assertEmpty($data['data']); + } } From 389c0fb0c981fe60df853cd26982f051e18ab705 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 18:52:57 +0100 Subject: [PATCH 51/60] Switch from user_id to username in share endpoint --- src/Controller/Api/ApiController.php | 82 +++++++++++++++++----------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 62859874..7d9752ca 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -98,7 +98,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, stri } $data = [ - 'id' => $user->getId(), + 'principal_id' => $user->getId(), 'uri' => $user->getUri(), 'username' => $user->getUsername(), 'displayname' => $user->getDisplayName(), @@ -295,7 +295,8 @@ public function createNewUserCalendar(Request $request, string $username, Manage } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); - $calendarInstance + try { + $calendarInstance ->setCalendar($calendar) ->setAccess(CalendarInstance::ACCESS_SHAREDOWNER) ->setDescription($calendarDescription) @@ -303,8 +304,11 @@ public function createNewUserCalendar(Request $request, string $username, Manage ->setUri($calendarURI) ->setPrincipalUri(Principal::PREFIX.$username); - $entityManager->persist($calendarInstance); - $entityManager->flush(); + $entityManager->persist($calendarInstance); + $entityManager->flush(); + } catch (\Exception $e) { + return $this->json(['status' => 'error', 'message' => 'Failed to Create Calendar', 'timestamp' => $this->getTimestamp()], 500); + } $response = [ 'status' => 'success', @@ -384,8 +388,12 @@ public function editUserCalendar(Request $request, string $username, int $calend } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); - $entityManager->persist($calendarInstance); - $entityManager->flush(); + try { + $entityManager->persist($calendarInstance); + $entityManager->flush(); + } catch (\Exception $e) { + return $this->json(['status' => 'error', 'message' => 'Failed to Edit Calendar', 'timestamp' => $this->getTimestamp()], 500); + } return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } @@ -470,14 +478,14 @@ public function setUserCalendarsShare(Request $request, string $username, int $c return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } - $userId = $data['user_id'] ?? null; + $shareeUsername = $data['username'] ?? null; $writeAccess = $data['write_access'] ?? null; - if (!is_numeric($userId) || !in_array($writeAccess, [true, false, 'true', 'false'], true)) { + if (!$this->validateUsername($shareeUsername) || !in_array($writeAccess, [true, false, 'true', 'false'], true)) { return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID/Write Access Value', 'timestamp' => $this->getTimestamp()], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); - $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($userId); + $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$shareeUsername); if (!$instance || !$newShareeToAdd) { return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found', 'timestamp' => $this->getTimestamp()], 404); @@ -487,21 +495,25 @@ public function setUserCalendarsShare(Request $request, string $username, int $c $accessLevel = (true === $writeAccess || 'true' === $writeAccess ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); $entityManager = $doctrine->getManager(); - if ($existingSharedInstance) { - $existingSharedInstance->setAccess($accessLevel); - } else { - $sharedInstance = new CalendarInstance(); - $sharedInstance->setTransparent(1) - ->setCalendar($instance->getCalendar()) - ->setShareHref('mailto:'.$newShareeToAdd->getEmail()) - ->setDescription($instance->getDescription()) - ->setDisplayName($instance->getDisplayName()) - ->setUri(\Sabre\DAV\UUIDUtil::getUUID()) - ->setPrincipalUri($newShareeToAdd->getUri()) - ->setAccess($accessLevel); - $entityManager->persist($sharedInstance); - } - $entityManager->flush(); + try { + if ($existingSharedInstance) { + $existingSharedInstance->setAccess($accessLevel); + } else { + $sharedInstance = new CalendarInstance(); + $sharedInstance->setTransparent(1) + ->setCalendar($instance->getCalendar()) + ->setShareHref('mailto:'.$newShareeToAdd->getEmail()) + ->setDescription($instance->getDescription()) + ->setDisplayName($instance->getDisplayName()) + ->setUri(\Sabre\DAV\UUIDUtil::getUUID()) + ->setPrincipalUri($newShareeToAdd->getUri()) + ->setAccess($accessLevel); + $entityManager->persist($sharedInstance); + } + $entityManager->flush(); + } catch (\Exception $e) { + return $this->json(['status' => 'error', 'message' => 'Failed to Edit Calendar', 'timestamp' => $this->getTimestamp()], 500); + } return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } @@ -537,24 +549,28 @@ public function removeUserCalendarsShare(Request $request, string $username, int return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } - $userId = $data['user_id'] ?? null; - if (!is_numeric($userId)) { - return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID', 'timestamp' => $this->getTimestamp()], 400); + $shareeUsername = $data['username'] ?? null; + if (!$this->validateUsername($shareeUsername)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Username', 'timestamp' => $this->getTimestamp()], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); - $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneById($userId); + $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$shareeUsername); if (!$instance || !$shareeToRemove) { return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found', 'timestamp' => $this->getTimestamp()], 404); } - $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $shareeToRemove->getUri()); + try { + $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $shareeToRemove->getUri()); - if ($existingSharedInstance) { - $entityManager = $doctrine->getManager(); - $entityManager->remove($existingSharedInstance); - $entityManager->flush(); + if ($existingSharedInstance) { + $entityManager = $doctrine->getManager(); + $entityManager->remove($existingSharedInstance); + $entityManager->flush(); + } + } catch (\Exception $e) { + return $this->json(['status' => 'error', 'message' => 'Failed to Remove Share', 'timestamp' => $this->getTimestamp()], 500); } return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); From 2619ec32432ca94ac006364325800beb2ababfba Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 19:04:01 +0100 Subject: [PATCH 52/60] Final API endpoint version + Docs update --- docs/api/v1/calendars/all.md | 12 ++++++------ docs/api/v1/calendars/share_add.md | 6 +++--- docs/api/v1/calendars/share_remove.md | 8 ++++---- docs/api/v1/users/all.md | 2 +- docs/api/v1/users/details.md | 2 +- src/Controller/Api/ApiController.php | 24 +++++++++++++++--------- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/api/v1/calendars/all.md b/docs/api/v1/calendars/all.md index df0e2642..45e15fb2 100644 --- a/docs/api/v1/calendars/all.md +++ b/docs/api/v1/calendars/all.md @@ -24,6 +24,8 @@ Gets a list of all available calendars for a specific user. **Code** : `200 OK` +**Notes**: The `events`, `notes`, and `tasks` fields return a count (number) if the component is enabled for the calendar, or `null` if the component is disabled. + **Content examples** ```json @@ -35,10 +37,9 @@ Gets a list of all available calendars for a specific user. "id": 1, "uri": "default", "displayname": "Default Calendar", - "description": "Default Calendar for John Doe", "events": 0, - "notes": 0, - "tasks": 0 + "notes": null, + "tasks": null } ], "shared_calendars": [ @@ -46,10 +47,9 @@ Gets a list of all available calendars for a specific user. "id": 10, "uri": "c2152eb0-ada1-451f-bf33-b4a9571ec92e", "displayname": "Default Calendar", - "description": "Default Calendar for Mark Doe", "events": 0, - "notes": 0, - "tasks": 0 + "notes": null, + "tasks": null } ], "subscriptions": [] diff --git a/docs/api/v1/calendars/share_add.md b/docs/api/v1/calendars/share_add.md index 6373e1e2..73cbde0d 100644 --- a/docs/api/v1/calendars/share_add.md +++ b/docs/api/v1/calendars/share_add.md @@ -18,7 +18,7 @@ Shares (or updates write access) a calendar owned by the specified user to anoth ** Request Body constraints** ```json { - "user_id": "[numeric id of the user to add/update access]", + "username": "[username of the user to add/update access]", "write_access": "[boolean: true to grant write access, false for read-only]" } ``` @@ -33,7 +33,7 @@ Shares (or updates write access) a calendar owned by the specified user to anoth ```json { - "user_id": "3", + "username": "jdoe", "write_access": true } ``` @@ -117,7 +117,7 @@ or } ``` -**Condition** : If 'user_id' is not numeric or 'write_access' is not 'true' or 'false' string. +**Condition** : If 'username' is not valid or 'write_access' is not 'true' or 'false'. **Code** : `400 BAD REQUEST` diff --git a/docs/api/v1/calendars/share_remove.md b/docs/api/v1/calendars/share_remove.md index 7c90032b..cadf3d72 100644 --- a/docs/api/v1/calendars/share_remove.md +++ b/docs/api/v1/calendars/share_remove.md @@ -18,7 +18,7 @@ Removes access to a specific shared calendar for a specific user. ** Request Body Constraints** ```json { - "user_id": "[numeric id of the user to remove access]" + "username": "[username of the user to remove access]" } ``` @@ -32,7 +32,7 @@ Removes access to a specific shared calendar for a specific user. ```json { - "user_id": "3" + "username": "jdoe" } ``` @@ -115,7 +115,7 @@ or } ``` -**Condition** : If 'user_id' is not numeric. +**Condition** : If 'username' is not valid. **Code** : `400 BAD REQUEST` @@ -124,7 +124,7 @@ or ```json { "status": "error", - "message": "Invalid Sharee ID", + "message": "Invalid Username", "timestamp": "2026-01-23T15:01:33+01:00" } ``` diff --git a/docs/api/v1/users/all.md b/docs/api/v1/users/all.md index f438d0f2..8e988777 100644 --- a/docs/api/v1/users/all.md +++ b/docs/api/v1/users/all.md @@ -19,7 +19,7 @@ Gets a list of all available users. "status": "success", "data": [ { - "id": 3, + "principal_id": 3, "uri": "principals/jdoe", "username": "jdoe" } diff --git a/docs/api/v1/users/details.md b/docs/api/v1/users/details.md index 35baf7e4..c98e96ef 100644 --- a/docs/api/v1/users/details.md +++ b/docs/api/v1/users/details.md @@ -30,7 +30,7 @@ Gets details about a specific user account. { "status": "success", "data": { - "id": 3, + "principal_id": 3, "uri": "principals/jdoe", "username": "jdoe", "displayname": "John Doe", diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 7d9752ca..30b41d2c 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -65,7 +65,7 @@ public function getUsers(Request $request, ManagerRegistry $doctrine): JsonRespo $users = []; foreach ($principals as $principal) { $users[] = [ - 'id' => $principal->getId(), + 'principal_id' => $principal->getId(), 'uri' => $principal->getUri(), 'username' => $principal->getUsername(), ]; @@ -136,14 +136,17 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi $sharedCalendars = []; foreach ($allCalendars as $calendar) { $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($calendar->getCalendar()->getId()); + $eventsCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS) ? $objectCounts['events'] : null; + $notesCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES) ? $objectCounts['notes'] : null; + $tasksCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS) ? $objectCounts['tasks'] : null; + $calendarData = [ 'id' => $calendar->getId(), 'uri' => $calendar->getUri(), 'displayname' => $calendar->getDisplayName(), - 'description' => $calendar->getDescription(), - 'events' => $objectCounts['events'], - 'notes' => $objectCounts['notes'], - 'tasks' => $objectCounts['tasks'], + 'events' => $eventsCount, + 'notes' => $notesCount, + 'tasks' => $tasksCount, ]; if (!$calendar->isShared()) { $calendars[] = $calendarData; @@ -155,14 +158,17 @@ public function getUserCalendars(Request $request, string $username, ManagerRegi $subscriptions = []; foreach ($allSubscriptions as $subscription) { $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($subscription->getCalendar()->getId()); + $eventsCount = $subscription->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS) ? $objectCounts['events'] : null; + $notesCount = $subscription->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES) ? $objectCounts['notes'] : null; + $tasksCount = $subscription->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS) ? $objectCounts['tasks'] : null; + $subscriptions[] = [ 'id' => $subscription->getId(), 'uri' => $subscription->getUri(), 'displayname' => $subscription->getDisplayName(), - 'description' => $subscription->getDescription(), - 'events' => $objectCounts['events'], - 'notes' => $objectCounts['notes'], - 'tasks' => $objectCounts['tasks'], + 'events' => $eventsCount, + 'notes' => $notesCount, + 'tasks' => $tasksCount, ]; } From 8a1f3adfbe77130e4fb7b504a8610c032b6038ed Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 19:04:21 +0100 Subject: [PATCH 53/60] Final API tests suite --- .env.test | 2 +- tests/Functional/ApiControllerTest.php | 64 +++++--------------------- 2 files changed, 12 insertions(+), 54 deletions(-) diff --git a/.env.test b/.env.test index 198b65d0..e8bda916 100644 --- a/.env.test +++ b/.env.test @@ -9,4 +9,4 @@ DATABASE_URL="mysql://davis:davis@127.0.0.1:3306/davis_test?serverVersion=10.9.3 MAILER_DSN=smtp://localhost:465?encryption=ssl&auth_mode=login&username=&password= -API_KEY= \ No newline at end of file +API_KEY=change_me \ No newline at end of file diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php index c1fec1f0..d707988a 100644 --- a/tests/Functional/ApiControllerTest.php +++ b/tests/Functional/ApiControllerTest.php @@ -32,32 +32,6 @@ private function getUserUsername($client, int $index): string return $data['data'][$index]['username']; } - /* - * Helper function to get an existing user ID from the user list - * - * @param mixed $client - * @param int $index Index of the user in the list (0 - first user, 1 - second user) - * - * @return int User ID - */ - private function getUserId($client, int $index): int - { - $client->request('GET', '/api/v1/users', [], [], [ - 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('Content-Type', 'application/json'); - - $data = json_decode($client->getResponse()->getContent(), true); - - $this->assertArrayHasKey('data', $data); - $this->assertStringContainsString('test_user', $data['data'][$index]['username']); - - return $data['data'][$index]['id']; - } - /* * Helper function to get an existing calendar ID from the user calendar list * @@ -156,14 +130,14 @@ public function testUserList(): void $data = json_decode($client->getResponse()->getContent(), true); // Check if user1 is present in db - $this->assertArrayHasKey('id', $data['data'][0]); + $this->assertArrayHasKey('principal_id', $data['data'][0]); $this->assertArrayHasKey('uri', $data['data'][0]); $this->assertStringContainsString('principals/test_user', $data['data'][0]['uri']); $this->assertArrayHasKey('username', $data['data'][0]); $this->assertStringContainsString('test_user', $data['data'][0]['username']); // Check if user2 is present in db - $this->assertArrayHasKey('id', $data['data'][1]); + $this->assertArrayHasKey('principal_id', $data['data'][1]); $this->assertArrayHasKey('uri', $data['data'][1]); $this->assertStringContainsString('principals/test_user2', $data['data'][1]['uri']); $this->assertArrayHasKey('username', $data['data'][1]); @@ -350,7 +324,6 @@ public function testEditUserCalendar(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); @@ -393,24 +366,25 @@ public function testGetUserCalendarSharesEmpty(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); $this->assertEmpty($data['data']); } - // TODO: FIX WHY IT DOES NOT PERSISTS BETWEEN REQUESTS + /* + * Test sharing user calendar to another user + */ public function testShareUserCalendar(): void { $client = static::createClient(); $username = $this->getUserUsername($client, 0); - $shareeUserId = $this->getUserId($client, 1); + $shareeUsername = $this->getUserUsername($client, 1); $calendarId = $this->getCalendarId($client, $username, true); // Share user default calendar to test_user2 $payload = [ - 'user_id' => $shareeUserId, + 'username' => $shareeUsername, 'write_access' => false, ]; $client->request('POST', '/api/v1/calendars/'.$username.'/share/'.$calendarId.'/add', [], [], [ @@ -422,25 +396,11 @@ public function testShareUserCalendar(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); - var_dump($data); - - // Check if share was applied - $client->request('GET', '/api/v1/calendars/'.$username.'/shares/'.$calendarId, [], [], [ - 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], - ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('Content-Type', 'application/json'); - $data2 = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data2); - $this->assertArrayHasKey('status', $data2); - $this->assertEquals('success', $data2['status']); - $this->assertArrayHasKey('data', $data2); - var_dump($data2); + // Note: The sharee is not returned by the API endpoint even though the share was created + // successfully and is visible in the database. This check only verifies the response is successful. } /* @@ -450,12 +410,12 @@ public function testUnshareUserCalendar(): void { $client = static::createClient(); $username = $this->getUserUsername($client, 0); - $shareeUserId = $this->getUserId($client, 1); + $shareeUsername = $this->getUserUsername($client, 1); $calendarId = $this->getCalendarId($client, $username, true); // Unshare user default calendar from test_user2 $payload = [ - 'user_id' => $shareeUserId, + 'username' => $shareeUsername, ]; $client->request('POST', '/api/v1/calendars/'.$username.'/share/'.$calendarId.'/remove', [], [], [ 'HTTP_ACCEPT' => 'application/json', @@ -466,7 +426,6 @@ public function testUnshareUserCalendar(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); $this->assertEquals('success', $data['status']); // Check if unshare was applied @@ -478,7 +437,6 @@ public function testUnshareUserCalendar(): void $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); - $this->assertIsArray($data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); $this->assertEmpty($data['data']); From dcf4c6f05430b93def4cb967383ea4e8f8ab4cb9 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 19:08:36 +0100 Subject: [PATCH 54/60] Adjust UserControllerTest to match new DataFixture state --- tests/Functional/UserControllerTest.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/Functional/UserControllerTest.php b/tests/Functional/UserControllerTest.php index 90f03834..a52acd24 100644 --- a/tests/Functional/UserControllerTest.php +++ b/tests/Functional/UserControllerTest.php @@ -20,7 +20,7 @@ public function testUserIndex(): void $this->assertSelectorExists('nav.navbar'); $this->assertSelectorTextContains('h1', 'Users and Resources'); $this->assertSelectorTextContains('a.btn', '+ New User'); - $this->assertSelectorTextContains('h5', 'Test User'); + $this->assertAnySelectorTextContains('h5', 'Test User'); } public function testUserEdit(): void @@ -41,7 +41,7 @@ public function testUserEdit(): void $this->assertResponseRedirects('/users/'); $client->followRedirect(); - $this->assertSelectorTextContains('h5', 'Test User'); + $this->assertAnySelectorTextContains('h5', 'Test User'); } public function testUserNew(): void @@ -72,7 +72,7 @@ public function testUserNew(): void $this->assertResponseRedirects('/users/'); $client->followRedirect(); - $this->assertSelectorTextContains('h5', 'Test User'); + $this->assertAnySelectorTextContains('h5', 'Test User'); $this->assertAnySelectorTextContains('h5', 'New test User'); } @@ -87,6 +87,13 @@ public function testUserDelete(): void $this->assertResponseRedirects('/users/'); $client->followRedirect(); + $this->assertAnySelectorTextContains('h5', 'Test User 2'); + + $client->request('GET', '/users/delete/test_user2'); + + $this->assertResponseRedirects('/users/'); + $client->followRedirect(); + $this->assertSelectorTextContains('div#no-user', 'No users yet.'); } From 66399d67415ae17d96706021ee87b8076521870d Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 20:09:43 +0100 Subject: [PATCH 55/60] Attempted fix to Github CI error - Check if calendar enabled components is empty --- docs/api/v1/calendars/create.md | 14 ++++++ docs/api/v1/calendars/edit.md | 14 ++++++ src/Controller/Api/ApiController.php | 12 ++++- tests/Functional/ApiControllerTest.php | 64 ++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/docs/api/v1/calendars/create.md b/docs/api/v1/calendars/create.md index 6f7a9188..d11be111 100644 --- a/docs/api/v1/calendars/create.md +++ b/docs/api/v1/calendars/create.md @@ -170,3 +170,17 @@ or "timestamp": "2026-01-23T15:01:33+01:00" } ``` + +**Condition** : If no calendar components are enabled (all of `events_support`, `notes_support`, and `tasks_support` are false). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "At least one calendar component must be enabled (events, notes, or tasks)", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` diff --git a/docs/api/v1/calendars/edit.md b/docs/api/v1/calendars/edit.md index 1f77a46e..d5a5516f 100644 --- a/docs/api/v1/calendars/edit.md +++ b/docs/api/v1/calendars/edit.md @@ -164,4 +164,18 @@ or "message": "Invalid Calendar Description", "timestamp": "2026-01-23T15:01:33+01:00" } +``` + +**Condition** : If no calendar components are enabled (all of `events_support`, `notes_support`, and `tasks_support` are false). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "At least one calendar component must be enabled (events, notes, or tasks)", + "timestamp": "2026-01-23T15:01:33+01:00" +} ``` \ No newline at end of file diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 30b41d2c..3ce17707 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -299,7 +299,12 @@ public function createNewUserCalendar(Request $request, string $username, Manage if (true === $tasksSupport || 'true' === $tasksSupport) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } - $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); + + // Validate that at least one component is selected + if (empty($calendarComponents)) { + return $this->json(['status' => 'error', 'message' => 'At least one calendar component must be enabled (events, notes, or tasks)', 'timestamp' => $this->getTimestamp()], 400); + } + $calendar->setComponents(implode(',', $calendarComponents)); try { $calendarInstance @@ -392,6 +397,11 @@ public function editUserCalendar(Request $request, string $username, int $calend if (true === $tasksSupport || 'true' === $tasksSupport) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } + + // Validate that at least one component is selected + if (empty($calendarComponents)) { + return $this->json(['status' => 'error', 'message' => 'At least one calendar component must be enabled (events, notes, or tasks)', 'timestamp' => $this->getTimestamp()], 400); + } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); try { diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php index d707988a..ed7c99e1 100644 --- a/tests/Functional/ApiControllerTest.php +++ b/tests/Functional/ApiControllerTest.php @@ -441,4 +441,68 @@ public function testUnshareUserCalendar(): void $this->assertArrayHasKey('data', $data); $this->assertEmpty($data['data']); } + + /* + * Test creating a calendar with no components enabled should return validation error + */ + public function testCreateUserCalendarNoComponents(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + + // Create calendar API request with no components enabled + $payload = [ + 'uri' => 'no_components_calendar', + 'name' => 'no.components.calendar', + 'description' => 'no.components.description', + 'events_support' => false, + 'tasks_support' => false, + 'notes_support' => false, + ]; + + $client->request('POST', '/api/v1/calendars/'.$username.'/create', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertStringContainsString('At least one calendar component must be enabled', $data['message']); + } + + /* + * Test editing a calendar with no components enabled should return validation error + */ + public function testEditUserCalendarNoComponents(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + $calendarId = $this->getCalendarId($client, $username, true); + + // Edit calendar API request with no components enabled + $payload = [ + 'name' => 'edited.calendar.title', + 'description' => 'edited.calendar.description', + 'events_support' => false, + 'tasks_support' => false, + 'notes_support' => false, + ]; + + $client->request('POST', '/api/v1/calendars/'.$username.'/'.$calendarId.'/edit', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertStringContainsString('At least one calendar component must be enabled', $data['message']); + } } From 24d6746bfa27d69f91974892481ef9872cddbc8c Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 20:11:33 +0100 Subject: [PATCH 56/60] Commit ommited change to ApiController --- src/Controller/Api/ApiController.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 3ce17707..a12ccb1c 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -286,7 +286,6 @@ public function createNewUserCalendar(Request $request, string $username, Manage $calendarInstance->setCalendar($calendar); $calendarComponents = []; - // Handle both boolean and string values $eventsSupport = $data['events_support'] ?? true; if (true === $eventsSupport || 'true' === $eventsSupport) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; @@ -384,7 +383,6 @@ public function editUserCalendar(Request $request, string $username, int $calend $calendarInstance->setDescription($calendarDescription); $calendarComponents = []; - // Handle both boolean and string values $eventsSupport = $data['events_support'] ?? true; if (true === $eventsSupport || 'true' === $eventsSupport) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; From 5def1eb4886f505cdddd296ebe90b8ab66ac25ff Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 22:15:19 +0100 Subject: [PATCH 57/60] Update access types to match upstream/main & Fix to /shares endpoint --- src/Controller/Api/ApiController.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index a12ccb1c..c4511a91 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -6,6 +6,7 @@ use App\Entity\CalendarInstance; use App\Entity\CalendarSubscription; use App\Entity\Principal; +use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -308,7 +309,7 @@ public function createNewUserCalendar(Request $request, string $username, Manage try { $calendarInstance ->setCalendar($calendar) - ->setAccess(CalendarInstance::ACCESS_SHAREDOWNER) + ->setAccess(SharingPlugin::ACCESS_SHAREDOWNER) ->setDescription($calendarDescription) ->setDisplayName($calendarName) ->setUri($calendarURI) @@ -437,18 +438,19 @@ public function getUserCalendarsShares(Request $request, string $username, int $ return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); } - $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendar_id, true); - + // This fixes the issue where shared calendars are not being retrieved properly + $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($ownerInstance->getCalendar()->getId(), true); + $calendars = []; foreach ($instances as $instance) { - $user_id = $doctrine->getRepository(Principal::class)->findOneByUri($instance[0]['principalUri']); + $principalId = $doctrine->getRepository(Principal::class)->findOneByUri($instance[0]['principalUri']); $calendars[] = [ 'username' => mb_substr($instance[0]['principalUri'], strlen(Principal::PREFIX)), - 'user_id' => $user_id?->getId() ?? null, + 'principal_id' => $principalId?->getId() ?? null, 'displayname' => $instance['displayName'], 'email' => $instance['email'], - 'write_access' => CalendarInstance::ACCESS_READWRITE === $instance[0]['access'], + 'write_access' => SharingPlugin::ACCESS_READWRITE === $instance[0]['access'], ]; } @@ -506,7 +508,7 @@ public function setUserCalendarsShare(Request $request, string $username, int $c } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); - $accessLevel = (true === $writeAccess || 'true' === $writeAccess ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); + $accessLevel = (true === $writeAccess || 'true' === $writeAccess ? SharingPlugin::ACCESS_READWRITE : SharingPlugin::ACCESS_READ); $entityManager = $doctrine->getManager(); try { From 15165b1c1d43f7e5b2529a26b161861e026f9341 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 22:15:30 +0100 Subject: [PATCH 58/60] Updated docs --- docs/api/v1/calendars/shares.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/api/v1/calendars/shares.md b/docs/api/v1/calendars/shares.md index 42b6b9a3..0776ddfd 100644 --- a/docs/api/v1/calendars/shares.md +++ b/docs/api/v1/calendars/shares.md @@ -21,6 +21,8 @@ Gets a list of all users with whom a specific user calendar is shared. /api/v1/calendars/mdoe/shares/1 ``` +**Important Note** : The `:calendar_id` must be a calendar instance owned by the user. The endpoint retrieves shares of the underlying Calendar entity, ensuring shares are found correctly regardless of the instance reference. + ## Success Response **Code** : `200 OK` @@ -33,14 +35,14 @@ Gets a list of all users with whom a specific user calendar is shared. "data": [ { "username": "adoe", - "user_id": 9, + "principal_id": 9, "displayname": "Aiden Doe", "email": "adoe@example.org", "write_access": false }, { "username": "jdoe", - "user_id": 3, + "principal_id": 3, "displayname": "John Doe", "email": "jdoe@example.org", "write_access": true From 4d3d3d796def04abee933222dce1ce52883f82e9 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 22:15:48 +0100 Subject: [PATCH 59/60] Add missing check from test --- tests/Functional/ApiControllerTest.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php index ed7c99e1..439d198f 100644 --- a/tests/Functional/ApiControllerTest.php +++ b/tests/Functional/ApiControllerTest.php @@ -399,8 +399,19 @@ public function testShareUserCalendar(): void $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); - // Note: The sharee is not returned by the API endpoint even though the share was created - // successfully and is visible in the database. This check only verifies the response is successful. + // Check if share was applied + $client->request('GET', '/api/v1/calendars/'.$username.'/shares/'.$calendarId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('data', $data); + $this->assertStringContainsString($shareeUsername, $data['data'][0]['username']); + $this->assertFalse($data['data'][0]['write_access']); } /* From 3f2f661ab0460a1a7589e53ff02148c57f65a219 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 5 Feb 2026 22:16:03 +0100 Subject: [PATCH 60/60] Code Linting --- src/Controller/Api/ApiController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index c4511a91..57513a1a 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -6,8 +6,8 @@ use App\Entity\CalendarInstance; use App\Entity\CalendarSubscription; use App\Entity\Principal; -use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Doctrine\Persistence\ManagerRegistry; +use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -440,7 +440,7 @@ public function getUserCalendarsShares(Request $request, string $username, int $ // This fixes the issue where shared calendars are not being retrieved properly $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($ownerInstance->getCalendar()->getId(), true); - + $calendars = []; foreach ($instances as $instance) { $principalId = $doctrine->getRepository(Principal::class)->findOneByUri($instance[0]['principalUri']);