From dde3eac9366faf8e8899716673e767efb6270db4 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 18 Mar 2026 18:11:46 -0500 Subject: [PATCH 1/3] feat: Add dropbox_sync_enabled flag and materializer proxy routes Add Summit model flag to control Dropbox sync activation per-summit, and proxy routes that forward admin requests to the dropbox-materializer microservice through Summit API's OAuth2 auth layer. - Doctrine migration for DropboxSyncEnabled column - Summit model property, serializer, validation, factory - DropboxMaterializerApi Guzzle client with HMAC X-Internal-Key auth - Controller with admin permission checks and sync-enabled gating - Routes: materialize, materializeRoom, backfill, rebuild, preflight, status Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 + .../SummitValidationRulesFactory.php | 2 + .../OAuth2SummitDropboxSyncApiController.php | 187 ++++++++++++++++++ .../Summit/SummitSerializer.php | 2 + .../Summit/Factories/SummitFactory.php | 4 + app/Models/Foundation/Summit/Summit.php | 23 +++ app/Services/Apis/DropboxMaterializerApi.php | 157 +++++++++++++++ app/Services/Apis/IDropboxMaterializerApi.php | 58 ++++++ app/Services/BaseServicesProvider.php | 7 + config/dropbox_materializer.php | 7 + .../model/Version20260318120000.php | 40 ++++ routes/api_v1.php | 11 ++ 12 files changed, 503 insertions(+) create mode 100644 app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitDropboxSyncApiController.php create mode 100644 app/Services/Apis/DropboxMaterializerApi.php create mode 100644 app/Services/Apis/IDropboxMaterializerApi.php create mode 100644 config/dropbox_materializer.php create mode 100644 database/migrations/model/Version20260318120000.php diff --git a/.env.example b/.env.example index a117d69258..e3309971c8 100644 --- a/.env.example +++ b/.env.example @@ -266,6 +266,11 @@ PAYMENTS_SERVICE_OAUTH2_CLIENT_ID= PAYMENTS_SERVICE_OAUTH2_CLIENT_SECRET= PAYMENTS_SERVICE_OAUTH2_SCOPES=payment-profile/read +# DROPBOX MATERIALIZER SERVICE + +DROPBOX_MATERIALIZER_URL=http://localhost:8100 +DROPBOX_MATERIALIZER_KEY= +DROPBOX_MATERIALIZER_TIMEOUT=10 # L5_FORMAT_TO_USE_FOR_DOCS=yaml # L5_SWAGGER_GENERATE_ALWAYS=true # Dev setting diff --git a/app/Http/Controllers/Apis/Protected/Summit/Factories/SummitValidationRulesFactory.php b/app/Http/Controllers/Apis/Protected/Summit/Factories/SummitValidationRulesFactory.php index deb1c69655..f1fa5b2af5 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/Factories/SummitValidationRulesFactory.php +++ b/app/Http/Controllers/Apis/Protected/Summit/Factories/SummitValidationRulesFactory.php @@ -98,6 +98,7 @@ public static function buildForAdd(array $payload = []): array 'registration_send_order_email_automatically' => 'sometimes|boolean', 'registration_allow_automatic_reminder_emails' => 'sometimes|boolean', 'allow_update_attendee_extra_questions' => 'sometimes|boolean', + 'dropbox_sync_enabled' => 'sometimes|boolean', 'time_zone_label' => 'sometimes|string', 'registration_allowed_refund_request_till_date' => 'nullable|date_format:U|epoch_seconds', 'registration_slug_prefix' => 'required|string|max:50', @@ -181,6 +182,7 @@ public static function buildForUpdate(array $payload = []): array 'registration_send_order_email_automatically' => 'sometimes|boolean', 'registration_allow_automatic_reminder_emails' => 'sometimes|boolean', 'allow_update_attendee_extra_questions' => 'sometimes|boolean', + 'dropbox_sync_enabled' => 'sometimes|boolean', 'time_zone_label' => 'sometimes|string', 'registration_allowed_refund_request_till_date' => 'nullable|date_format:U|epoch_seconds', 'registration_slug_prefix' => 'sometimes|string|max:50', diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitDropboxSyncApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitDropboxSyncApiController.php new file mode 100644 index 0000000000..4ddf485cee --- /dev/null +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitDropboxSyncApiController.php @@ -0,0 +1,187 @@ +repository = $summit_repository; + $this->materializer_api = $materializer_api; + } + + /** + * @param Summit $summit + * @return void + * @throws \Exception + */ + private function checkAdminPermission(Summit $summit): void + { + $current_member = $this->resource_server_context->getCurrentUser(); + if (!is_null($current_member) && !$current_member->isAdmin() && !$current_member->hasPermissionForOnGroup($summit, IGroup::SummitAdministrators)) + throw new AuthzException( + sprintf("Member %s has not permission for this Summit", $current_member->getId()) + ); + } + + /** + * @param int $summit_id + * @return Summit + * @throws EntityNotFoundException + */ + private function findSummit(int $summit_id): Summit + { + $summit = $this->repository->getById($summit_id); + if (is_null($summit) || !$summit instanceof Summit) + throw new EntityNotFoundException(sprintf("Summit %s not found", $summit_id)); + return $summit; + } + + /** + * @param Summit $summit + * @throws ValidationException + */ + private function requireSyncEnabled(Summit $summit): void + { + if (!$summit->isDropboxSyncEnabled()) + throw new ValidationException("Dropbox sync is not enabled for this summit."); + } + + /** + * POST /api/v1/summits/{id}/dropbox-sync/materialize + */ + public function materialize($summit_id) + { + return $this->processRequest(function () use ($summit_id) { + $summit = $this->findSummit(intval($summit_id)); + $this->checkAdminPermission($summit); + $this->requireSyncEnabled($summit); + + $result = $this->materializer_api->materialize($summit->getId()); + return $this->ok($result); + }); + } + + /** + * POST /api/v1/summits/{id}/dropbox-sync/materialize/{location_id}/{room_id} + */ + public function materializeRoom($summit_id, $location_id, $room_id) + { + return $this->processRequest(function () use ($summit_id, $location_id, $room_id) { + $summit = $this->findSummit(intval($summit_id)); + $this->checkAdminPermission($summit); + $this->requireSyncEnabled($summit); + + $venue = $summit->getLocation(intval($location_id)); + if (is_null($venue) || !$venue instanceof SummitVenue) + throw new EntityNotFoundException(sprintf("Venue %s not found", $location_id)); + + $room = $venue->getRoom(intval($room_id)); + if (is_null($room)) + throw new EntityNotFoundException(sprintf("Room %s not found", $room_id)); + + $result = $this->materializer_api->materializeRoom( + $summit->getId(), + $venue->getName(), + $room->getName() + ); + return $this->ok($result); + }); + } + + /** + * POST /api/v1/summits/{id}/dropbox-sync/backfill + */ + public function backfill($summit_id) + { + return $this->processRequest(function () use ($summit_id) { + $summit = $this->findSummit(intval($summit_id)); + $this->checkAdminPermission($summit); + $this->requireSyncEnabled($summit); + + $result = $this->materializer_api->backfill($summit->getId()); + return $this->ok($result); + }); + } + + /** + * POST /api/v1/summits/{id}/dropbox-sync/rebuild + */ + public function rebuild($summit_id) + { + return $this->processRequest(function () use ($summit_id) { + $summit = $this->findSummit(intval($summit_id)); + $this->checkAdminPermission($summit); + + $result = $this->materializer_api->rebuild($summit->getId()); + return $this->ok($result); + }); + } + + /** + * GET /api/v1/summits/{id}/dropbox-sync/preflight + */ + public function preflight($summit_id) + { + return $this->processRequest(function () use ($summit_id) { + $summit = $this->findSummit(intval($summit_id)); + $this->checkAdminPermission($summit); + + $result = $this->materializer_api->preflight($summit->getId()); + return $this->ok($result); + }); + } + + /** + * GET /api/v1/summits/{id}/dropbox-sync/status + */ + public function status($summit_id) + { + return $this->processRequest(function () use ($summit_id) { + $summit = $this->findSummit(intval($summit_id)); + $this->checkAdminPermission($summit); + + $result = $this->materializer_api->status($summit->getId()); + return $this->ok($result); + }); + } +} diff --git a/app/ModelSerializers/Summit/SummitSerializer.php b/app/ModelSerializers/Summit/SummitSerializer.php index 45f71321c8..ff3f165fa8 100644 --- a/app/ModelSerializers/Summit/SummitSerializer.php +++ b/app/ModelSerializers/Summit/SummitSerializer.php @@ -91,6 +91,7 @@ class SummitSerializer extends SilverStripeSerializer 'RegistrationAllowAutomaticReminderEmails' => 'registration_allow_automatic_reminder_emails:json_boolean', 'Modality' => 'modality:json_string', 'AllowUpdateAttendeeExtraQuestions' => 'allow_update_attendee_extra_questions:json_boolean', + 'DropboxSyncEnabled' => 'dropbox_sync_enabled:json_boolean', 'TimeZoneLabel' => 'time_zone_label:json_string', 'RegistrationAllowedRefundRequestTillDate' => 'registration_allowed_refund_request_till_date:datetime_epoch', 'RegistrationSlugPrefix' => 'registration_slug_prefix:json_string', @@ -162,6 +163,7 @@ class SummitSerializer extends SilverStripeSerializer 'registration_allow_automatic_reminder_emails', 'modality', 'allow_update_attendee_extra_questions', + 'dropbox_sync_enabled', 'time_zone_label', 'registration_allowed_refund_request_till_date', 'registration_slug_prefix', diff --git a/app/Models/Foundation/Summit/Factories/SummitFactory.php b/app/Models/Foundation/Summit/Factories/SummitFactory.php index b7289fadf6..05e4e0d84d 100644 --- a/app/Models/Foundation/Summit/Factories/SummitFactory.php +++ b/app/Models/Foundation/Summit/Factories/SummitFactory.php @@ -83,6 +83,10 @@ public static function populate(Summit $summit, array $data){ $summit->setAllowUpdateAttendeeExtraQuestions(boolval($data['allow_update_attendee_extra_questions'])); } + if(isset($data['dropbox_sync_enabled'])){ + $summit->setDropboxSyncEnabled(boolval($data['dropbox_sync_enabled'])); + } + if(isset($data['dates_label']) ){ $summit->setDatesLabel(trim($data['dates_label'])); } diff --git a/app/Models/Foundation/Summit/Summit.php b/app/Models/Foundation/Summit/Summit.php index c10a6b3e97..c054742d15 100644 --- a/app/Models/Foundation/Summit/Summit.php +++ b/app/Models/Foundation/Summit/Summit.php @@ -498,6 +498,12 @@ public function setMarketingSiteOauth2ClientScopes(string $marketing_site_oauth2 #[ORM\Column(name: 'RegistrationAllowAutomaticReminderEmails', type: 'boolean')] private $registration_allow_automatic_reminder_emails; + /** + * @var bool + */ + #[ORM\Column(name: 'DropboxSyncEnabled', type: 'boolean')] + private $dropbox_sync_enabled; + #[ORM\OneToMany(targetEntity: \SummitEvent::class, mappedBy: 'summit', cascade: ['persist', 'remove'], orphanRemoval: true, fetch: 'EXTRA_LAZY')] private $events; @@ -1190,6 +1196,7 @@ public function __construct() $this->registration_allow_automatic_reminder_emails = true; $this->registration_send_order_email_automatically = true; $this->allow_update_attendee_extra_questions = false; + $this->dropbox_sync_enabled = false; $this->registration_companies = new ArrayCollection(); $this->external_registration_feed_last_ingest_date = null; $this->speakers_announcement_emails = new ArrayCollection(); @@ -6362,6 +6369,22 @@ public function setAllowUpdateAttendeeExtraQuestions(bool $allow_update_attendee $this->allow_update_attendee_extra_questions = $allow_update_attendee_extra_questions; } + /** + * @return bool + */ + public function isDropboxSyncEnabled(): bool + { + return $this->dropbox_sync_enabled; + } + + /** + * @param bool $dropbox_sync_enabled + */ + public function setDropboxSyncEnabled(bool $dropbox_sync_enabled): void + { + $this->dropbox_sync_enabled = $dropbox_sync_enabled; + } + /** * @return bool */ diff --git a/app/Services/Apis/DropboxMaterializerApi.php b/app/Services/Apis/DropboxMaterializerApi.php new file mode 100644 index 0000000000..e301cfe2f7 --- /dev/null +++ b/app/Services/Apis/DropboxMaterializerApi.php @@ -0,0 +1,157 @@ +push(GuzzleRetryMiddleware::factory()); + + $this->client = new Client([ + 'handler' => $stack, + 'base_uri' => Config::get('dropbox_materializer.base_url', 'http://localhost:8100'), + 'timeout' => Config::get('dropbox_materializer.timeout', 10), + 'allow_redirects' => false, + 'verify' => Config::get('curl.verify_ssl_cert', true), + ]); + + $this->internal_key = Config::get('dropbox_materializer.internal_key', ''); + } + + /** + * @return array + */ + private function headers(): array + { + return [ + 'X-Internal-Key' => $this->internal_key, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]; + } + + /** + * @param string $method + * @param string $uri + * @return array + */ + private function request(string $method, string $uri): array + { + try { + $response = $this->client->request($method, $uri, [ + 'headers' => $this->headers(), + ]); + + $body = $response->getBody()->getContents(); + return json_decode($body, true) ?? []; + } catch (RequestException $ex) { + Log::warning( + sprintf( + "DropboxMaterializerApi::request %s %s error: %s", + $method, + $uri, + $ex->getMessage() + ) + ); + + $response = $ex->getResponse(); + if ($response) { + $body = $response->getBody()->getContents(); + $decoded = json_decode($body, true); + return $decoded ?? ['error' => $ex->getMessage(), 'status' => $response->getStatusCode()]; + } + + return ['error' => $ex->getMessage()]; + } + } + + /** + * @param int $summitId + * @return array + */ + public function materialize(int $summitId): array + { + return $this->request('POST', "/api/sync/materialize/{$summitId}/"); + } + + /** + * @param int $summitId + * @param string $venue + * @param string $room + * @return array + */ + public function materializeRoom(int $summitId, string $venue, string $room): array + { + $venue = rawurlencode($venue); + $room = rawurlencode($room); + return $this->request('POST', "/api/sync/materialize/{$summitId}/{$venue}/{$room}/"); + } + + /** + * @param int $summitId + * @return array + */ + public function backfill(int $summitId): array + { + return $this->request('POST', "/api/sync/backfill/{$summitId}/"); + } + + /** + * @param int $summitId + * @return array + */ + public function rebuild(int $summitId): array + { + return $this->request('POST', "/api/sync/rebuild/{$summitId}/"); + } + + /** + * @param int $summitId + * @return array + */ + public function preflight(int $summitId): array + { + return $this->request('GET', "/api/sync/preflight/{$summitId}/"); + } + + /** + * @param int $summitId + * @return array + */ + public function status(int $summitId): array + { + return $this->request('GET', "/api/sync/status/{$summitId}/"); + } +} diff --git a/app/Services/Apis/IDropboxMaterializerApi.php b/app/Services/Apis/IDropboxMaterializerApi.php new file mode 100644 index 0000000000..1a318299c9 --- /dev/null +++ b/app/Services/Apis/IDropboxMaterializerApi.php @@ -0,0 +1,58 @@ + env('DROPBOX_MATERIALIZER_URL', 'http://localhost:8100'), + 'internal_key' => env('DROPBOX_MATERIALIZER_KEY', ''), + 'timeout' => env('DROPBOX_MATERIALIZER_TIMEOUT', 10), +]; diff --git a/database/migrations/model/Version20260318120000.php b/database/migrations/model/Version20260318120000.php new file mode 100644 index 0000000000..317e8d2bd8 --- /dev/null +++ b/database/migrations/model/Version20260318120000.php @@ -0,0 +1,40 @@ +addSql($sql); + } + + public function down(Schema $schema): void + { + $this->addSql("ALTER TABLE Summit DROP COLUMN DropboxSyncEnabled"); + } +} diff --git a/routes/api_v1.php b/routes/api_v1.php index 6c9d66b207..510bf0083e 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -277,6 +277,17 @@ ), 'uses' => 'OAuth2SummitApiController@getSummit'])->where('id', 'current|[0-9]+'); + // dropbox sync proxy routes + + Route::group(['prefix' => 'dropbox-sync'], function () { + Route::post('materialize', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitDropboxSyncApiController@materialize']); + Route::post('materialize/{location_id}/{room_id}', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitDropboxSyncApiController@materializeRoom']); + Route::post('backfill', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitDropboxSyncApiController@backfill']); + Route::post('rebuild', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitDropboxSyncApiController@rebuild']); + Route::get('preflight', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitDropboxSyncApiController@preflight']); + Route::get('status', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitDropboxSyncApiController@status']); + }); + // selection plan extra questions ( by summit ) Route::group(['prefix' => 'selection-plan-extra-questions'], function () { From f18a6a142194c16d55dc078be28b9bfec12528c2 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 18 Mar 2026 18:17:00 -0500 Subject: [PATCH 2/3] fix: Add DropboxSyncEnabled to initial_schema.sql and initial_migrations.sql Doctrine ORM reads column annotations at migration time, so the column must exist in the initial schema to avoid 'Unknown column' errors when earlier migrations touch the Summit entity. Co-Authored-By: Claude Opus 4.6 (1M context) --- database/migrations/model/initial_migrations.sql | 3 ++- database/migrations/model/initial_schema.sql | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/database/migrations/model/initial_migrations.sql b/database/migrations/model/initial_migrations.sql index db60205bcb..bcd41673a5 100644 --- a/database/migrations/model/initial_migrations.sql +++ b/database/migrations/model/initial_migrations.sql @@ -342,4 +342,5 @@ INSERT INTO DoctrineMigration (version, executed_at) VALUES ('Database\\Migratio INSERT INTO DoctrineMigration (version, executed_at) VALUES ('Database\\Migrations\\Model\\Version20240320151845', '2024-04-11 16:52:02'); INSERT INTO DoctrineMigration (version, executed_at) VALUES ('Database\\Migrations\\Model\\Version20240326133631', '2024-04-11 16:52:06'); INSERT INTO DoctrineMigration (version, executed_at) VALUES ('Database\\Migrations\\Model\\Version20240326133636', '2024-04-11 16:52:06'); -INSERT INTO DoctrineMigration (version, executed_at) VALUES ('Database\\Migrations\\Model\\Version20240410135620', '2024-04-11 16:52:06'); \ No newline at end of file +INSERT INTO DoctrineMigration (version, executed_at) VALUES ('Database\\Migrations\\Model\\Version20240410135620', '2024-04-11 16:52:06'); +INSERT INTO DoctrineMigration (version, executed_at) VALUES ('Database\\Migrations\\Model\\Version20260318120000', '2026-03-18 12:00:00'); \ No newline at end of file diff --git a/database/migrations/model/initial_schema.sql b/database/migrations/model/initial_schema.sql index d93e02c0c1..b4bbfa43cd 100644 --- a/database/migrations/model/initial_schema.sql +++ b/database/migrations/model/initial_schema.sql @@ -9129,6 +9129,7 @@ create table Summit SecondaryLogoID int null, SpeakersSupportEmail varchar(255) null, MarkAsDeleted tinyint unsigned default '0' not null, + DropboxSyncEnabled tinyint(1) default 0 not null, constraint QRCodesEncKey unique (QRCodesEncKey), constraint Summit_RegistrationSlugPrefix From 5b02de097a9e65045d4d52507dae2faa4a603222 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 18 Mar 2026 20:40:16 -0500 Subject: [PATCH 3/3] fix: Add IDropboxMaterializerApi to provides(), restrict retries to GET - Register in deferred provider's provides() so DI resolution works - Limit GuzzleRetryMiddleware to GET requests only (POST endpoints are idempotent but no reason to retry them on 429/503) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Services/Apis/DropboxMaterializerApi.php | 4 +++- app/Services/BaseServicesProvider.php | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Services/Apis/DropboxMaterializerApi.php b/app/Services/Apis/DropboxMaterializerApi.php index e301cfe2f7..d85e4a370f 100644 --- a/app/Services/Apis/DropboxMaterializerApi.php +++ b/app/Services/Apis/DropboxMaterializerApi.php @@ -37,7 +37,9 @@ final class DropboxMaterializerApi implements IDropboxMaterializerApi public function __construct() { $stack = HandlerStack::create(); - $stack->push(GuzzleRetryMiddleware::factory()); + $stack->push(GuzzleRetryMiddleware::factory([ + 'retry_on_methods' => ['GET'], + ])); $this->client = new Client([ 'handler' => $stack, diff --git a/app/Services/BaseServicesProvider.php b/app/Services/BaseServicesProvider.php index afd9fafbe7..7e2c26959c 100644 --- a/app/Services/BaseServicesProvider.php +++ b/app/Services/BaseServicesProvider.php @@ -199,6 +199,7 @@ public function provides() IPushNotificationApi::class, IGeoCodingAPI::class, IExternalUserApi::class, + IDropboxMaterializerApi::class, IFolderService::class, ILockManagerService::class, IPasswordlessAPI::class,