diff --git a/.env b/.env index a92624c3..47f3c7ff 100644 --- a/.env +++ b/.env @@ -90,10 +90,17 @@ 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 the php bin/console 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 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 diff --git a/.env.test b/.env.test index abacd241..e8bda916 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=change_me \ No newline at end of file diff --git a/README.md b/README.md index f78d94a0..8e79fe4a 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-Davis-API-Token` 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/config/packages/security.yaml b/config/packages/security.yaml index 7faaddf0..3b697cb8 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_v1: + pattern: ^/api/v1 + 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/v1/health$, roles: PUBLIC_ACCESS } + - { path: ^/api, roles: IS_AUTHENTICATED } diff --git a/config/services.yaml b/config/services.yaml index c5830166..25662b12 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -78,6 +78,10 @@ services: arguments: $birthdayReminderOffset: "%birthday_reminder_offset%" + App\Security\ApiKeyAuthenticator: + arguments: + $apiKey: "%env(API_KEY)%" + when@dev: services: Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 00000000..f75f9ed7 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,34 @@ +# Davis API + +## API Version 1 + +### Open Endpoints + +Open endpoints require no Authentication. + +* [Health](v1/health.md) : `GET /api/v1/health` + +### Endpoints that require 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: + +* [Get Users](v1/users/all.md) : `GET /api/v1/users` +* [Get User Details](v1/users/details.md) : `GET /api/v1/users/:username` + +#### Calendars related + +Endpoints for viewing and modifying user calendars. + +* [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/v1/calendars/all.md b/docs/api/v1/calendars/all.md new file mode 100644 index 00000000..45e15fb2 --- /dev/null +++ b/docs/api/v1/calendars/all.md @@ -0,0 +1,110 @@ +# User Calendars + +Gets a list of all available calendars for a specific user. + +**URL** : `/api/v1/calendars/:username` + +**Method** : `GET` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +``` + +**URL example** + +```json +/api/v1/calendars/jdoe +``` + +## Success Response + +**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 +{ + "status": "success", + "data": { + "user_calendars": [ + { + "id": 1, + "uri": "default", + "displayname": "Default Calendar", + "events": 0, + "notes": null, + "tasks": null + } + ], + "shared_calendars": [ + { + "id": 10, + "uri": "c2152eb0-ada1-451f-bf33-b4a9571ec92e", + "displayname": "Default Calendar", + "events": 0, + "notes": null, + "tasks": null + } + ], + "subscriptions": [] + }, + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +Shown when user does not have calendars: +```json +{ + "status": "success", + "data": { + "user_calendars": [], + "shared_calendars": [], + "subscriptions": [] + }, + "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" +} +``` \ 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..d11be111 --- /dev/null +++ b/docs/api/v1/calendars/create.md @@ -0,0 +1,186 @@ +# 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]", + "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]", + "tasks_support": "[string: 'true' or 'false', default 'false', optional]" +} +``` + +**URL example** + +``` +/api/v1/calendars/jdoe/create +``` + +**Body example** + +```json +{ + "name": "Work Calendar", + "uri": "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", + "data": { + "calendar_id": 5, + "calendar_uri": "work-calendar" + }, + "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 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` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar Name", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**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 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` + +**Content** : + +```json +{ + "status": "error", + "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" +} +``` diff --git a/docs/api/v1/calendars/details.md b/docs/api/v1/calendars/details.md new file mode 100644 index 00000000..ec91acce --- /dev/null +++ b/docs/api/v1/calendars/details.md @@ -0,0 +1,86 @@ +# User Calendar Details + +Gets a list of all available calendars for a specific user. + +**URL** : `/api/v1/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/v1/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": { + "enabled": true, + "count": 0 + }, + "notes": { + "enabled": false, + "count": 0 + }, + "tasks": { + "enabled": false, + "count": 0 + } + }, + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +Shown when user has no calendars with the given id: +```json +{ + "status": "success", + "data": {}, + "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" +} +``` \ 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..d5a5516f --- /dev/null +++ b/docs/api/v1/calendars/edit.md @@ -0,0 +1,181 @@ +# 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 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` + +**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" +} +``` + +**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/docs/api/v1/calendars/share_add.md b/docs/api/v1/calendars/share_add.md new file mode 100644 index 00000000..73cbde0d --- /dev/null +++ b/docs/api/v1/calendars/share_add.md @@ -0,0 +1,146 @@ +# 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 +{ + "username": "[username of the user to add/update 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 +{ + "username": "jdoe", + "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 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` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID and User ID", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'username' is not valid or 'write_access' is not 'true' or 'false'. + +**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..cadf3d72 --- /dev/null +++ b/docs/api/v1/calendars/share_remove.md @@ -0,0 +1,144 @@ +# 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 +{ + "username": "[username of the user to remove access]" +} +``` + +**URL example** + +```json +/api/v1/calendars/mdoe/share/1/remove +``` + +**Body example** + +```json +{ + "username": "jdoe" +} +``` + +## 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 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` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Calendar ID", + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +**Condition** : If 'username' is not valid. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Username", + "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/v1/calendars/shares.md b/docs/api/v1/calendars/shares.md new file mode 100644 index 00000000..0776ddfd --- /dev/null +++ b/docs/api/v1/calendars/shares.md @@ -0,0 +1,104 @@ +# User Calendar Shares + +Gets a list of all users with whom a specific user calendar is shared. + +**URL** : `/api/v1/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/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` + +**Content examples** + +```json +{ + "status": "success", + "data": [ + { + "username": "adoe", + "principal_id": 9, + "displayname": "Aiden Doe", + "email": "adoe@example.org", + "write_access": false + }, + { + "username": "jdoe", + "principal_id": 3, + "displayname": "John Doe", + "email": "jdoe@example.org", + "write_access": true + } + ] +} +``` + +## 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' and ':username' combination is invalid. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "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/v1/health.md b/docs/api/v1/health.md new file mode 100644 index 00000000..b20197ec --- /dev/null +++ b/docs/api/v1/health.md @@ -0,0 +1,22 @@ +# Health + +Used to check if the API endpoint is active. + +**URL** : `/api/v1/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/v1/users/all.md b/docs/api/v1/users/all.md new file mode 100644 index 00000000..8e988777 --- /dev/null +++ b/docs/api/v1/users/all.md @@ -0,0 +1,62 @@ +# Get Users + +Gets a list of all available users. + +**URL** : `/api/v1/users` + +**Method** : `GET` + +**Auth required** : YES + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": [ + { + "principal_id": 3, + "uri": "principals/jdoe", + "username": "jdoe" + } + ], + "timestamp": "2026-01-23T15:01:33+01:00" +} +``` + +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-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" +} +``` diff --git a/docs/api/v1/users/details.md b/docs/api/v1/users/details.md new file mode 100644 index 00000000..c98e96ef --- /dev/null +++ b/docs/api/v1/users/details.md @@ -0,0 +1,78 @@ +# User Details + +Gets details about a specific user account. + +**URL** : `/api/v1/users/:username` + +**Method** : `GET` + +**Auth required** : YES + +**Params constraints** + +``` +:username -> "[username in plain text]", +``` + +**URL example** + +```json +/api/v1/users/jdoe +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": { + "principal_id": 3, + "uri": "principals/jdoe", + "username": "jdoe", + "displayname": "John Doe", + "email": "jdoe@example.org" + }, + "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" +} \ No newline at end of file diff --git a/src/Command/ApiGenerateCommand.php b/src/Command/ApiGenerateCommand.php new file mode 100644 index 00000000..5d4cdf27 --- /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; + } +} diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php new file mode 100644 index 00000000..57513a1a --- /dev/null +++ b/src/Controller/Api/ApiController.php @@ -0,0 +1,594 @@ +json(['status' => 'OK', 'timestamp' => $this->getTimestamp()], 200); + } + + /** + * 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 + */ + #[Route('/users', name: 'users', methods: ['GET'])] + public function getUsers(Request $request, ManagerRegistry $doctrine): JsonResponse + { + $principals = $doctrine->getRepository(Principal::class)->findByIsMain(true); + + $users = []; + foreach ($principals as $principal) { + $users[] = [ + 'principal_id' => $principal->getId(), + 'uri' => $principal->getUri(), + 'username' => $principal->getUsername(), + ]; + } + + $response = [ + 'status' => 'success', + 'data' => $users, + 'timestamp' => $this->getTimestamp(), + ]; + + return $this->json($response, 200); + } + + /** + * 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 + * + * @return JsonResponse A JSON response containing the user details + */ + #[Route('/users/{username}', name: 'user_detail', methods: ['GET'], requirements: ['username' => '[a-zA-Z0-9_-]+'])] + public function getUserDetails(Request $request, ManagerRegistry $doctrine, string $username): JsonResponse + { + $user = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username); + + if (!$user) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + + $data = [ + 'principal_id' => $user->getId(), + 'uri' => $user->getUri(), + 'username' => $user->getUsername(), + 'displayname' => $user->getDisplayName(), + 'email' => $user->getEmail(), + ]; + + $response = [ + 'status' => 'success', + 'data' => $data, + 'timestamp' => $this->getTimestamp(), + ]; + + return $this->json($response, 200); + } + + /** + * 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 for the specified user + */ + #[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); + + $calendars = []; + $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(), + 'events' => $eventsCount, + 'notes' => $notesCount, + 'tasks' => $tasksCount, + ]; + if (!$calendar->isShared()) { + $calendars[] = $calendarData; + } else { + $sharedCalendars[] = $calendarData; + } + } + + $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(), + 'events' => $eventsCount, + 'notes' => $notesCount, + 'tasks' => $tasksCount, + ]; + } + + $response = [ + 'status' => 'success', + 'data' => [ + 'user_calendars' => $calendars, + 'shared_calendars' => $sharedCalendars, + 'subscriptions' => $subscriptions, + ], + 'timestamp' => $this->getTimestamp(), + ]; + + return $this->json($response, 200); + } + + /** + * 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 + * @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'], 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 = []; + foreach ($allCalendars as $calendar) { + if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { + $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' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS), + 'count' => $objectCounts['events'], + ], + 'notes' => [ + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES), + 'count' => $objectCounts['notes'], + ], + 'tasks' => [ + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS), + 'count' => $objectCounts['tasks'], + ], + ]; + } + } + + $response = [ + 'status' => 'success', + 'data' => $calendar_details, + '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 + { + if (!$doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username)) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (JSON_ERROR_NONE !== json_last_error()) { + 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 = $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); + } + + $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); + } + + $entityManager = $doctrine->getManager(); + $calendarInstance = new CalendarInstance(); + $calendar = new Calendar(); + $calendarInstance->setCalendar($calendar); + + $calendarComponents = []; + $eventsSupport = $data['events_support'] ?? true; + if (true === $eventsSupport || 'true' === $eventsSupport) { + $calendarComponents[] = Calendar::COMPONENT_EVENTS; + } + $notesSupport = $data['notes_support'] ?? false; + if (true === $notesSupport || 'true' === $notesSupport) { + $calendarComponents[] = Calendar::COMPONENT_NOTES; + } + $tasksSupport = $data['tasks_support'] ?? false; + 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); + } + $calendar->setComponents(implode(',', $calendarComponents)); + + try { + $calendarInstance + ->setCalendar($calendar) + ->setAccess(SharingPlugin::ACCESS_SHAREDOWNER) + ->setDescription($calendarDescription) + ->setDisplayName($calendarName) + ->setUri($calendarURI) + ->setPrincipalUri(Principal::PREFIX.$username); + + $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', + '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 + */ + #[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); + } + + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (JSON_ERROR_NONE !== json_last_error()) { + 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 = $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); + } + + $entityManager = $doctrine->getManager(); + $calendarInstance->setDisplayName($calendarName); + $calendarInstance->setDescription($calendarDescription); + + $calendarComponents = []; + $eventsSupport = $data['events_support'] ?? true; + if (true === $eventsSupport || 'true' === $eventsSupport) { + $calendarComponents[] = Calendar::COMPONENT_EVENTS; + } + $notesSupport = $data['notes_support'] ?? false; + if (true === $notesSupport || 'true' === $notesSupport) { + $calendarComponents[] = Calendar::COMPONENT_NOTES; + } + $tasksSupport = $data['tasks_support'] ?? false; + 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 { + $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); + } + + /** + * 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 + * @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); + } + + // 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']); + + $calendars[] = [ + 'username' => mb_substr($instance[0]['principalUri'], strlen(Principal::PREFIX)), + 'principal_id' => $principalId?->getId() ?? null, + 'displayname' => $instance['displayName'], + 'email' => $instance['email'], + 'write_access' => SharingPlugin::ACCESS_READWRITE === $instance[0]['access'], + ]; + } + + $response = [ + 'status' => 'success', + 'data' => $calendars, + 'timestamp' => $this->getTimestamp(), + ]; + + return $this->json($response, 200); + } + + /** + * 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}/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 and User ID', 'timestamp' => $this->getTimestamp()], 400); + } + + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (JSON_ERROR_NONE !== json_last_error()) { + return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); + } + + $shareeUsername = $data['username'] ?? null; + $writeAccess = $data['write_access'] ?? null; + 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)->findOneByUri(Principal::PREFIX.$shareeUsername); + + if (!$instance || !$newShareeToAdd) { + 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()); + $accessLevel = (true === $writeAccess || 'true' === $writeAccess ? SharingPlugin::ACCESS_READWRITE : SharingPlugin::ACCESS_READ); + $entityManager = $doctrine->getManager(); + + 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); + } + + /** + * 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}/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', 'timestamp' => $this->getTimestamp()], 400); + } + + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (JSON_ERROR_NONE !== json_last_error()) { + return $this->json(['status' => 'error', 'message' => 'Invalid JSON', '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)->findOneByUri(Principal::PREFIX.$shareeUsername); + + if (!$instance || !$shareeToRemove) { + return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + + try { + $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $shareeToRemove->getUri()); + + 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); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 5b84dccc..53a1f454 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,49 @@ 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(false); + $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.title2') + ->setDescription('default.calendar.description2') + ->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.title2') + ->setDescription('default.addressbook.description2'); + $manager->persist($addressbook); + + $manager->flush(); } } 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; + } } diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php new file mode 100644 index 00000000..d0375395 --- /dev/null +++ b/src/Security/ApiKeyAuthenticator.php @@ -0,0 +1,71 @@ +apiKey = $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; + } + + public function authenticate(Request $request): Passport + { + $apiToken = $request->headers->get('X-Davis-API-Token'); + if (null === $apiToken) { + throw new CustomUserMessageAuthenticationException('Missing X-Davis-API-Token header'); + } + + if (false === hash_equals($this->apiKey, $apiToken)) { + throw new CustomUserMessageAuthenticationException('Invalid X-Davis-API-Token header'); + } + + 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 = [ + 'status' => 'error', + 'message' => $exception->getMessage(), + 'timestamp' => date('c'), + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/ApiControllerTest.php new file mode 100644 index 00000000..439d198f --- /dev/null +++ b/tests/Functional/ApiControllerTest.php @@ -0,0 +1,519 @@ +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]['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); + + 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']; + } + } + + /* + * 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 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 + */ + 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); + + // Check if user1 is present in db + $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('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]); + $this->assertStringContainsString('test_user2', $data['data'][1]['username']); + } + + /* + * 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, 0); + + // 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); + + // 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 + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + + $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); + + // Check if calendar list is correct + $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']); + } + + /* + * Test the user calendar details endpoint + */ + public function testUserCalendarDetails(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + + // 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); + $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); + + // 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->assertArrayHasKey('notes', $data['data']); + $this->assertArrayHasKey('tasks', $data['data']); + } + + /* + * Test creating a new user calendar + */ + public function testCreateUserCalendar(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + + // Create calendar 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->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->assertArrayHasKey('events', $data['data']); + $this->assertTrue($data['data']['events']['enabled']); + $this->assertArrayHasKey('tasks', $data['data']); + $this->assertTrue($data['data']['tasks']['enabled']); + $this->assertArrayHasKey('notes', $data['data']); + $this->assertFalse($data['data']['notes']['enabled']); + } + + /* + * Test editing a user calendar + */ + public function testEditUserCalendar(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + $calendarId = $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.'/'.$calendarId.'/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->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + + // Check if edits were applied + $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->assertArrayHasKey('data', $data); + $this->assertStringContainsString($payload['name'], $data['data']['displayname']); + $this->assertStringContainsString($payload['description'], $data['data']['description']); + + $this->assertArrayHasKey('events', $data['data']); + $this->assertTrue($data['data']['events']['enabled']); + $this->assertArrayHasKey('tasks', $data['data']); + $this->assertTrue($data['data']['tasks']['enabled']); + $this->assertArrayHasKey('notes', $data['data']); + $this->assertTrue($data['data']['notes']['enabled']); + } + + /* + * 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->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('data', $data); + $this->assertEmpty($data['data']); + } + + /* + * Test sharing user calendar to another user + */ + public function testShareUserCalendar(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + $shareeUsername = $this->getUserUsername($client, 1); + $calendarId = $this->getCalendarId($client, $username, true); + + // Share user default calendar to test_user2 + $payload = [ + 'username' => $shareeUsername, + '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->assertArrayHasKey('status', $data); + $this->assertEquals('success', $data['status']); + + // 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']); + } + + /* + * Test removing shared access to user calendar + */ + public function testUnshareUserCalendar(): void + { + $client = static::createClient(); + $username = $this->getUserUsername($client, 0); + $shareeUsername = $this->getUserUsername($client, 1); + $calendarId = $this->getCalendarId($client, $username, true); + + // Unshare user default calendar from test_user2 + $payload = [ + 'username' => $shareeUsername, + ]; + $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->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->assertEquals('success', $data['status']); + $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']); + } +} 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.'); }