From 637d4b9beb9e3f7bec56fceaabd3f82e31ad9013 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Tue, 24 Feb 2026 16:06:41 -0800 Subject: [PATCH 1/7] feat(db): add api_clients table and model Add migration for api_clients table with token hashing, scopes, allowed origins, network restrictions, and rate limiting fields. Include Eloquent model with casts and factory for testing. Co-Authored-By: Claude Opus 4.6 --- app/Models/ApiClient.php | 51 +++++++++++++++++++ database/factories/ApiClientFactory.php | 30 +++++++++++ ..._02_24_000000_create_api_clients_table.php | 36 +++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 app/Models/ApiClient.php create mode 100644 database/factories/ApiClientFactory.php create mode 100644 database/migrations/2026_02_24_000000_create_api_clients_table.php diff --git a/app/Models/ApiClient.php b/app/Models/ApiClient.php new file mode 100644 index 0000000000..4e5b234113 --- /dev/null +++ b/app/Models/ApiClient.php @@ -0,0 +1,51 @@ + 'array', + 'allowed_origins' => 'array', + 'allowed_network_ids' => 'array', + 'active' => 'boolean', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + ]; + } + + public function hasScope(string $scope): bool + { + $scopes = $this->scopes ?: []; + return in_array($scope, $scopes, true); + } + + public function hasExpired(): bool + { + return $this->expires_at && Carbon::now()->greaterThan($this->expires_at); + } +} diff --git a/database/factories/ApiClientFactory.php b/database/factories/ApiClientFactory.php new file mode 100644 index 0000000000..accf353c77 --- /dev/null +++ b/database/factories/ApiClientFactory.php @@ -0,0 +1,30 @@ + + */ +class ApiClientFactory extends Factory +{ + protected $model = ApiClient::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->company(), + 'token_hash' => hash('sha256', Str::random(64)), + 'scopes' => ['events:read'], + 'allowed_origins' => null, + 'allowed_network_ids' => null, + 'rate_limit_per_minute' => 120, + 'active' => true, + 'expires_at' => null, + 'last_used_at' => null, + ]; + } +} diff --git a/database/migrations/2026_02_24_000000_create_api_clients_table.php b/database/migrations/2026_02_24_000000_create_api_clients_table.php new file mode 100644 index 0000000000..e873e1af63 --- /dev/null +++ b/database/migrations/2026_02_24_000000_create_api_clients_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('name'); + $table->string('token_hash', 64)->unique(); + $table->json('scopes')->nullable(); + $table->json('allowed_origins')->nullable(); + $table->json('allowed_network_ids')->nullable(); + $table->unsignedInteger('rate_limit_per_minute')->default(120); + $table->boolean('active')->default(true); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_clients'); + } +}; From 8837b565cd55f4b034fde77087cc466c5e713c7d Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Tue, 24 Feb 2026 16:07:16 -0800 Subject: [PATCH 2/7] feat(config): add public_events_api feature flag Add FEATURE__PUBLIC_EVENTS_API environment variable (default: false) across .env.base, .env.template, Helm values, and restarters config. Co-Authored-By: Claude Opus 4.6 --- .env.base | 3 ++- .env.template | 3 ++- charts/restarters/values.yaml | 1 + config/restarters.php | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.env.base b/.env.base index 6908894c5a..41b4907162 100644 --- a/.env.base +++ b/.env.base @@ -28,6 +28,7 @@ FEATURE__WIKI_INTEGRATION=false FEATURE__DISCOURSE_INTEGRATION=false FEATURE__AUTO_APPROVE_GROUPS=false FEATURE__AUTO_APPROVE_EVENTS=false +FEATURE__PUBLIC_EVENTS_API=false # ============================================================================= # LOGGING CONFIGURATION @@ -183,4 +184,4 @@ SEEDING_TRUNCATE_SKILLS=true L5_SWAGGER_GENERATE_ALWAYS=true REPAIRDIRECTORY_URL=http://map.restarters.test META_TWITTER_SITE= -META_TWITTER_IMAGE_ALT= \ No newline at end of file +META_TWITTER_IMAGE_ALT= diff --git a/.env.template b/.env.template index 795cfea043..dd6eeacf9e 100644 --- a/.env.template +++ b/.env.template @@ -28,6 +28,7 @@ FEATURE__WIKI_INTEGRATION="$FEATURE__WIKI_INTEGRATION" FEATURE__DISCOURSE_INTEGRATION="$FEATURE__DISCOURSE_INTEGRATION" FEATURE__AUTO_APPROVE_GROUPS="$FEATURE__AUTO_APPROVE_GROUPS" FEATURE__AUTO_APPROVE_EVENTS="$FEATURE__AUTO_APPROVE_EVENTS" +FEATURE__PUBLIC_EVENTS_API="$FEATURE__PUBLIC_EVENTS_API" # ============================================================================= # LOGGING CONFIGURATION @@ -183,4 +184,4 @@ SEEDING_TRUNCATE_SKILLS="$SEEDING_TRUNCATE_SKILLS" L5_SWAGGER_GENERATE_ALWAYS="$L5_SWAGGER_GENERATE_ALWAYS" REPAIRDIRECTORY_URL="$REPAIRDIRECTORY_URL" META_TWITTER_SITE="$META_TWITTER_SITE" -META_TWITTER_IMAGE_ALT="$META_TWITTER_IMAGE_ALT" \ No newline at end of file +META_TWITTER_IMAGE_ALT="$META_TWITTER_IMAGE_ALT" diff --git a/charts/restarters/values.yaml b/charts/restarters/values.yaml index 19c403229e..a3ffb3dbb6 100644 --- a/charts/restarters/values.yaml +++ b/charts/restarters/values.yaml @@ -237,6 +237,7 @@ envGroups: FEATURE__DISCOURSE_INTEGRATION: "false" FEATURE__AUTO_APPROVE_GROUPS: "false" FEATURE__AUTO_APPROVE_EVENTS: "false" + FEATURE__PUBLIC_EVENTS_API: "false" # Logging configuration logging: diff --git a/config/restarters.php b/config/restarters.php index 0bddff53ee..e7cc396533 100644 --- a/config/restarters.php +++ b/config/restarters.php @@ -7,6 +7,7 @@ 'wordpress_integration' => env('FEATURE__WORDPRESS_INTEGRATION', true), 'image_upload_enabled' => env('FEATURE__IMAGE_UPLOAD', false), 'matomo_integration' => env('FEATURE__MATOMO_INTEGRATION', false), + 'public_events_api' => env('FEATURE__PUBLIC_EVENTS_API', false), ], 'auth' => [ From 72591e658c70bd5925163b94689dce463c88a82e Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Tue, 24 Feb 2026 16:07:59 -0800 Subject: [PATCH 3/7] feat(middleware): add public events API middleware stack Add four middleware classes for the public API: - EnsurePublicEventsApiEnabled: gates access behind feature flag - PublicApiCors: handles CORS headers for cross-origin requests - AuthenticateApiClient: validates bearer tokens against api_clients - EnforceApiClientOrigin: restricts requests to allowed origins Register all four as named aliases in bootstrap/app.php. Co-Authored-By: Claude Opus 4.6 --- app/Http/Middleware/AuthenticateApiClient.php | 46 +++++++++++++++ .../Middleware/EnforceApiClientOrigin.php | 58 +++++++++++++++++++ .../EnsurePublicEventsApiEnabled.php | 19 ++++++ app/Http/Middleware/PublicApiCors.php | 35 +++++++++++ bootstrap/app.php | 4 ++ 5 files changed, 162 insertions(+) create mode 100644 app/Http/Middleware/AuthenticateApiClient.php create mode 100644 app/Http/Middleware/EnforceApiClientOrigin.php create mode 100644 app/Http/Middleware/EnsurePublicEventsApiEnabled.php create mode 100644 app/Http/Middleware/PublicApiCors.php diff --git a/app/Http/Middleware/AuthenticateApiClient.php b/app/Http/Middleware/AuthenticateApiClient.php new file mode 100644 index 0000000000..22df35c009 --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiClient.php @@ -0,0 +1,46 @@ +bearerToken(); + + if (!$bearerToken) { + return $this->unauthorized(); + } + + $client = ApiClient::where('token_hash', hash('sha256', $bearerToken))->first(); + + if (!$client || !$client->active || $client->hasExpired()) { + return $this->unauthorized(); + } + + if ($requiredScope && !$client->hasScope($requiredScope)) { + return response()->json([ + 'message' => 'Forbidden.', + ], 403); + } + + $request->attributes->set('apiClient', $client); + + $client->last_used_at = now(); + $client->save(); + + return $next($request); + } + + private function unauthorized(): Response + { + return response()->json([ + 'message' => 'Unauthenticated.', + ], 401); + } +} diff --git a/app/Http/Middleware/EnforceApiClientOrigin.php b/app/Http/Middleware/EnforceApiClientOrigin.php new file mode 100644 index 0000000000..178f018ac7 --- /dev/null +++ b/app/Http/Middleware/EnforceApiClientOrigin.php @@ -0,0 +1,58 @@ +attributes->get('apiClient'); + + if (!$client) { + return response()->json([ + 'message' => 'Unauthenticated.', + ], 401); + } + + $requestOrigin = $request->headers->get('Origin'); + $allowedOrigins = $this->normalizeOrigins($client->allowed_origins); + + if ($requestOrigin && !empty($allowedOrigins)) { + $normalizedRequestOrigin = $this->normalizeOrigin($requestOrigin); + + if (!in_array($normalizedRequestOrigin, $allowedOrigins, true)) { + return response()->json([ + 'message' => 'Origin not allowed.', + ], 403); + } + } + + return $next($request); + } + + private function normalizeOrigins(?array $origins): array + { + if (!$origins) { + return []; + } + + return array_values(array_filter(array_map(function ($origin) { + return $this->normalizeOrigin($origin); + }, $origins))); + } + + private function normalizeOrigin(?string $origin): ?string + { + if (!$origin) { + return null; + } + + return strtolower(rtrim(trim($origin), '/')); + } +} diff --git a/app/Http/Middleware/EnsurePublicEventsApiEnabled.php b/app/Http/Middleware/EnsurePublicEventsApiEnabled.php new file mode 100644 index 0000000000..566b95a7f2 --- /dev/null +++ b/app/Http/Middleware/EnsurePublicEventsApiEnabled.php @@ -0,0 +1,19 @@ +isMethod('OPTIONS')) { + $response = response()->noContent(); + return $this->addHeaders($request, $response); + } + + $response = $next($request); + return $this->addHeaders($request, $response); + } + + private function addHeaders(Request $request, Response $response): Response + { + $origin = $request->headers->get('Origin'); + $allowOrigin = $origin ?: '*'; + + $response->headers->set('Access-Control-Allow-Origin', $allowOrigin); + $response->headers->set('Access-Control-Allow-Methods', 'GET, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Authorization, Content-Type'); + $response->headers->set('Access-Control-Max-Age', '3600'); + $response->headers->set('Vary', 'Origin', false); + + return $response; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 06da62039a..c394ef6816 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -85,10 +85,14 @@ 'AcceptUserInvites' => \App\Http\Middleware\AcceptUserInvites::class, 'customApiAuth' => \App\Http\Middleware\CustomApiTokenAuth::class, 'admin' => \App\Http\Middleware\AdminMiddleware::class, + 'apiClient' => \App\Http\Middleware\AuthenticateApiClient::class, + 'apiClientOrigin' => \App\Http\Middleware\EnforceApiClientOrigin::class, 'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class, 'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class, 'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class, 'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class, + 'publicEventsApiEnabled' => \App\Http\Middleware\EnsurePublicEventsApiEnabled::class, + 'publicApiCors' => \App\Http\Middleware\PublicApiCors::class, 'centralizedAuth' => \App\Http\Middleware\CentralizedAuth::class, ]); From 1210688f081887d05f2321c469736b43081409fd Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Tue, 24 Feb 2026 16:08:47 -0800 Subject: [PATCH 4/7] feat(api): add public events endpoints with rate limiting Add PublicEventController with three endpoints: - GET /public/v2/events (list with date/pagination filters) - GET /public/v2/events/{id} (single event) - GET /public/v2/groups/{id}/events (events by group) Register public/v2 route group with middleware stack and add per-client rate limiter (public-api) to bootstrap/app.php. Co-Authored-By: Claude Opus 4.6 --- .../Controllers/API/PublicEventController.php | 148 ++++++++++++++++++ bootstrap/app.php | 8 + routes/api.php | 15 +- 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/API/PublicEventController.php diff --git a/app/Http/Controllers/API/PublicEventController.php b/app/Http/Controllers/API/PublicEventController.php new file mode 100644 index 0000000000..eba065ddfb --- /dev/null +++ b/app/Http/Controllers/API/PublicEventController.php @@ -0,0 +1,148 @@ +listWithFilters($request); + } + + public function showEvent(Request $request, int $id): JsonResponse + { + $query = $this->buildBaseEventQuery(); + $this->applyClientRestrictions($query, $request); + + $event = $query + ->where('events.idevents', $id) + ->firstOrFail(); + + return response()->json([ + 'data' => $this->toPublicEventArray($event), + ]); + } + + public function listGroupEvents(Request $request, int $id): JsonResponse + { + Group::findOrFail($id); + + return $this->listWithFilters($request, function (Builder $query) use ($id) { + $query->where('events.group', $id); + }); + } + + private function listWithFilters(Request $request, ?callable $filter = null): JsonResponse + { + $validated = $request->validate([ + 'start' => ['nullable', 'date'], + 'end' => ['nullable', 'date'], + 'updated_start' => ['nullable', 'date'], + 'updated_end' => ['nullable', 'date'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $query = $this->buildBaseEventQuery(); + $this->applyClientRestrictions($query, $request); + + if ($filter) { + $filter($query); + } + + $this->applyDateFilters($query, $validated); + + $maxUpdatedAt = (clone $query)->max('events.updated_at'); + + $perPage = (int) ($validated['per_page'] ?? 50); + $paginator = $query->paginate($perPage); + + return response()->json([ + 'data' => $paginator->getCollection()->map(function (Party $event) { + return $this->toPublicEventArray($event); + })->values(), + 'meta' => [ + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + 'sync' => [ + 'generated_at' => Carbon::now()->toIso8601String(), + 'max_updated_at' => $maxUpdatedAt ? Carbon::parse($maxUpdatedAt)->toIso8601String() : null, + ], + ]); + } + + private function toPublicEventArray(Party $event): array + { + $data = PartyResource::make($event)->resolve(); + + // Keep payload lightweight for third-party display use-cases. + unset($data['stats'], $data['network_data']); + + if (isset($data['group']) && is_array($data['group'])) { + unset($data['group']['networks']); + } + + return $data; + } + + private function buildBaseEventQuery(): Builder + { + return Party::query() + ->join('groups', 'groups.idgroups', '=', 'events.group') + ->whereNull('events.deleted_at') + ->whereNull('groups.deleted_at') + ->where('events.approved', true) + ->where('groups.approved', true) + ->distinct() + ->select('events.*') + ->orderBy('events.event_start_utc', 'asc'); + } + + private function applyClientRestrictions(Builder $query, Request $request): void + { + $client = $request->attributes->get('apiClient'); + $allowedNetworkIds = $client?->allowed_network_ids ?: []; + + if (!empty($allowedNetworkIds)) { + $query->join('group_network as permitted_network', 'permitted_network.group_id', '=', 'groups.idgroups') + ->whereIn('permitted_network.network_id', $allowedNetworkIds); + } + } + + private function applyDateFilters(Builder $query, array $validated): void + { + if (!empty($validated['start'])) { + $start = Carbon::parse($validated['start'])->setTimezone('UTC')->toIso8601String(); + $query->where('events.event_start_utc', '>=', $start); + } else { + $query->where('events.event_end_utc', '>=', Carbon::now()->setTimezone('UTC')->toIso8601String()); + } + + if (!empty($validated['end'])) { + $end = Carbon::parse($validated['end'])->setTimezone('UTC')->toIso8601String(); + $query->where('events.event_end_utc', '<=', $end); + } + + if (!empty($validated['updated_start'])) { + $updatedStart = Carbon::parse($validated['updated_start'])->setTimezone('UTC')->toDateTimeString(); + $query->where('events.updated_at', '>=', $updatedStart); + } + + if (!empty($validated['updated_end'])) { + $updatedEnd = Carbon::parse($validated['updated_end'])->setTimezone('UTC')->toDateTimeString(); + $query->where('events.updated_at', '<=', $updatedEnd); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c394ef6816..c4af8f3cf5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -33,6 +33,14 @@ RateLimiter::for('api', function (Request $request) { return Limit::perMinute(300)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('public-api', function (Request $request) { + $client = $request->attributes->get('apiClient'); + $perMinute = $client?->rate_limit_per_minute ?? 120; + $clientId = $client?->id ?: 'anonymous'; + + return Limit::perMinute($perMinute)->by("{$clientId}:{$request->ip()}"); + }); } ) ->withMiddleware(function (Middleware $middleware) { diff --git a/routes/api.php b/routes/api.php index 12ee7df2bf..411d80c4d2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,6 +43,19 @@ Route::get('timezone', [API\TimeZoneController::class, 'lookup']); }); +Route::prefix('public/v2') + ->withoutMiddleware('customApiAuth') + ->middleware(['publicEventsApiEnabled', 'publicApiCors']) + ->group(function () { + Route::options('{any}', fn () => response()->noContent())->where('any', '.*'); + + Route::middleware(['apiClient:events:read', 'apiClientOrigin', 'throttle:public-api'])->group(function () { + Route::get('events', [API\PublicEventController::class, 'listEvents']); + Route::get('events/{id}', [API\PublicEventController::class, 'showEvent']); + Route::get('groups/{id}/events', [API\PublicEventController::class, 'listGroupEvents']); + }); + }); + // ============================================================================= // AUTHENTICATED API ROUTES (v1 - Legacy) // ============================================================================= @@ -168,4 +181,4 @@ Route::post('{id}/{action}', [App\Http\Controllers\API\EventsController::class, 'performSingleAction']); }); }); -}); \ No newline at end of file +}); From cbb261196e6083622e3938a42df1f3dcb0e4327a Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Tue, 24 Feb 2026 16:09:13 -0800 Subject: [PATCH 5/7] feat(cli): add api-clients management commands Add three artisan commands for managing API client tokens: - api-clients:create: generate new client with scopes/origins/rate limit - api-clients:revoke: deactivate a client by ID - api-clients:rotate: replace a client's token with a new one Co-Authored-By: Claude Opus 4.6 --- app/Console/Commands/ApiClientsCreate.php | 90 +++++++++++++++++++++++ app/Console/Commands/ApiClientsRevoke.php | 43 +++++++++++ app/Console/Commands/ApiClientsRotate.php | 49 ++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 app/Console/Commands/ApiClientsCreate.php create mode 100644 app/Console/Commands/ApiClientsRevoke.php create mode 100644 app/Console/Commands/ApiClientsRotate.php diff --git a/app/Console/Commands/ApiClientsCreate.php b/app/Console/Commands/ApiClientsCreate.php new file mode 100644 index 0000000000..3bd6252882 --- /dev/null +++ b/app/Console/Commands/ApiClientsCreate.php @@ -0,0 +1,90 @@ +option('name')); + + if ($name === '') { + $this->error('The --name option is required.'); + return 1; + } + + $rate = (int) $this->option('rate'); + if ($rate < 1) { + $this->error('The --rate option must be greater than zero.'); + return 1; + } + + $scopes = $this->parseCsvOption((string) $this->option('scopes')); + $origins = $this->parseCsvOption((string) $this->option('origins')); + $networks = $this->parseCsvOption((string) $this->option('networks')); + $networkIds = array_values(array_filter(array_map('intval', $networks), fn (int $id) => $id > 0)); + + $expiresAt = null; + if ($this->option('expires-at')) { + $expiresAt = Carbon::parse((string) $this->option('expires-at')); + } + + $plainToken = Str::random(64); + + $client = ApiClient::create([ + 'name' => $name, + 'token_hash' => hash('sha256', $plainToken), + 'scopes' => $scopes ?: ['events:read'], + 'allowed_origins' => $origins ?: null, + 'allowed_network_ids' => $networkIds ?: null, + 'rate_limit_per_minute' => $rate, + 'active' => true, + 'expires_at' => $expiresAt, + ]); + + $this->info('API client created.'); + $this->line("ID: {$client->id}"); + $this->line("Name: {$client->name}"); + $this->line("Token: {$plainToken}"); + $this->warn('Store this token now. It will not be shown again.'); + + return 0; + } + + private function parseCsvOption(string $value): array + { + if (trim($value) === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode(',', $value)))); + } +} diff --git a/app/Console/Commands/ApiClientsRevoke.php b/app/Console/Commands/ApiClientsRevoke.php new file mode 100644 index 0000000000..0f464946ff --- /dev/null +++ b/app/Console/Commands/ApiClientsRevoke.php @@ -0,0 +1,43 @@ +argument('id')); + + if (!$client) { + $this->error('API client not found.'); + return 1; + } + + $client->active = false; + $client->save(); + + $this->info("Revoked API client {$client->id} ({$client->name})."); + + return 0; + } +} diff --git a/app/Console/Commands/ApiClientsRotate.php b/app/Console/Commands/ApiClientsRotate.php new file mode 100644 index 0000000000..203d0911b2 --- /dev/null +++ b/app/Console/Commands/ApiClientsRotate.php @@ -0,0 +1,49 @@ +argument('id')); + + if (!$client) { + $this->error('API client not found.'); + return 1; + } + + $plainToken = Str::random(64); + + $client->token_hash = hash('sha256', $plainToken); + $client->active = true; + $client->save(); + + $this->info("Rotated API client {$client->id} ({$client->name})."); + $this->line("Token: {$plainToken}"); + $this->warn('Store this token now. It will not be shown again.'); + + return 0; + } +} From ac9ae72db7d780be3ce169b814c8384091e29b95 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Tue, 24 Feb 2026 16:09:52 -0800 Subject: [PATCH 6/7] test: add public events API feature tests Cover authentication, query token rejection, date filtering, group filtering, network restrictions, origin enforcement, single event visibility, and updated_at window queries. Co-Authored-By: Claude Opus 4.6 --- tests/Feature/Events/PublicEventsApiTest.php | 203 +++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/Feature/Events/PublicEventsApiTest.php diff --git a/tests/Feature/Events/PublicEventsApiTest.php b/tests/Feature/Events/PublicEventsApiTest.php new file mode 100644 index 0000000000..3ad0977ac4 --- /dev/null +++ b/tests/Feature/Events/PublicEventsApiTest.php @@ -0,0 +1,203 @@ + true]); + } + + public function test_public_events_api_requires_bearer_key(): void + { + $response = $this->get('/api/public/v2/events'); + $response->assertStatus(401); + } + + public function test_public_events_api_ignores_query_token_auth(): void + { + $response = $this->get('/api/public/v2/events?api_token=not_a_valid_public_key'); + $response->assertStatus(401); + } + + public function test_public_events_api_lists_only_upcoming_approved_events_by_default(): void + { + $admin = $this->createUserWithToken(Role::ADMINISTRATOR); + $this->actingAs($admin); + + $approvedGroupId = $this->createGroup('Public API Group', 'https://example.com', 'London', 'Some text', true, true); + $futureApprovedId = $this->createEvent($approvedGroupId, 'tomorrow', true, true); + $pastApprovedId = $this->createEvent($approvedGroupId, 'yesterday', true, true); + $futureUnapprovedId = $this->createEvent($approvedGroupId, 'next week', true, false); + + $unapprovedGroupId = $this->createGroup('Hidden Group', 'https://example.com', 'London', 'Some text', true, false); + $hiddenEventId = $this->createEvent($unapprovedGroupId, 'next week', true, true); + + $token = $this->createPublicApiToken(); + $response = $this->withHeader('Authorization', 'Bearer ' . $token)->get('/api/public/v2/events'); + $response->assertSuccessful(); + + $json = json_decode($response->getContent(), true); + $ids = array_column($json['data'], 'id'); + + $this->assertContains($futureApprovedId, $ids); + $this->assertNotContains($pastApprovedId, $ids); + $this->assertNotContains($futureUnapprovedId, $ids); + $this->assertNotContains($hiddenEventId, $ids); + + $this->assertArrayHasKey('meta', $json); + $this->assertArrayHasKey('sync', $json); + $this->assertArrayHasKey('generated_at', $json['sync']); + $this->assertArrayHasKey('max_updated_at', $json['sync']); + $this->assertArrayHasKey('description', $json['data'][0]); + $this->assertArrayNotHasKey('stats', $json['data'][0]); + $this->assertArrayNotHasKey('network_data', $json['data'][0]); + $this->assertArrayNotHasKey('networks', $json['data'][0]['group']); + } + + public function test_public_events_api_supports_group_filters(): void + { + $admin = $this->createUserWithToken(Role::ADMINISTRATOR); + $this->actingAs($admin); + + $group1Id = $this->createGroup('Group One', 'https://example.com', 'London', 'Some text', true, true); + $group2Id = $this->createGroup('Group Two', 'https://example.com', 'London', 'Some text', true, true); + + $event1 = $this->createEvent($group1Id, 'tomorrow', true, true); + $event2 = $this->createEvent($group2Id, 'tomorrow', true, true); + + $token = $this->createPublicApiToken(); + + $groupResponse = $this->withHeader('Authorization', 'Bearer ' . $token)->get("/api/public/v2/groups/{$group1Id}/events"); + $groupResponse->assertSuccessful(); + $groupJson = json_decode($groupResponse->getContent(), true); + $groupIds = array_column($groupJson['data'], 'id'); + $this->assertContains($event1, $groupIds); + $this->assertNotContains($event2, $groupIds); + } + + public function test_public_events_api_respects_allowed_network_restrictions(): void + { + $admin = $this->createUserWithToken(Role::ADMINISTRATOR); + $this->actingAs($admin); + + $group1Id = $this->createGroup('Restricted Group One', 'https://example.com', 'London', 'Some text', true, true); + $group2Id = $this->createGroup('Restricted Group Two', 'https://example.com', 'London', 'Some text', true, true); + + $event1 = $this->createEvent($group1Id, 'tomorrow', true, true); + $event2 = $this->createEvent($group2Id, 'tomorrow', true, true); + + $allowedNetwork = Network::factory()->create(); + $blockedNetwork = Network::factory()->create(); + $allowedNetwork->addGroup(Group::findOrFail($group1Id)); + $blockedNetwork->addGroup(Group::findOrFail($group2Id)); + + $token = $this->createPublicApiToken([ + 'allowed_network_ids' => [$allowedNetwork->id], + ]); + + $response = $this->withHeader('Authorization', 'Bearer ' . $token)->get('/api/public/v2/events'); + $response->assertSuccessful(); + $json = json_decode($response->getContent(), true); + $ids = array_column($json['data'], 'id'); + + $this->assertContains($event1, $ids); + $this->assertNotContains($event2, $ids); + } + + public function test_public_events_api_enforces_allowed_origins_when_configured(): void + { + $admin = $this->createUserWithToken(Role::ADMINISTRATOR); + $this->actingAs($admin); + $groupId = $this->createGroup('Origin Test Group', 'https://example.com', 'London', 'Some text', true, true); + $this->createEvent($groupId, 'tomorrow', true, true); + + $token = $this->createPublicApiToken([ + 'allowed_origins' => ['https://allowed.example'], + ]); + + $forbidden = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + 'Origin' => 'https://disallowed.example', + ])->get('/api/public/v2/events'); + $forbidden->assertStatus(403); + + $allowed = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + 'Origin' => 'https://allowed.example', + ])->get('/api/public/v2/events'); + $allowed->assertSuccessful(); + } + + public function test_public_events_api_show_event_returns_only_public_approved_events(): void + { + $this->withExceptionHandling(); + + $admin = $this->createUserWithToken(Role::ADMINISTRATOR); + $this->actingAs($admin); + + $groupId = $this->createGroup('Show Event Group', 'https://example.com', 'London', 'Some text', true, true); + $approvedEventId = $this->createEvent($groupId, 'tomorrow', true, true); + $unapprovedEventId = $this->createEvent($groupId, 'next week', true, false); + + $token = $this->createPublicApiToken(); + + $visible = $this->withHeader('Authorization', 'Bearer ' . $token)->get("/api/public/v2/events/{$approvedEventId}"); + $visible->assertSuccessful(); + $json = json_decode($visible->getContent(), true); + $this->assertEquals($approvedEventId, $json['data']['id']); + $this->assertArrayNotHasKey('stats', $json['data']); + $this->assertArrayNotHasKey('network_data', $json['data']); + $this->assertArrayNotHasKey('networks', $json['data']['group']); + + $hidden = $this->withHeader('Authorization', 'Bearer ' . $token)->get("/api/public/v2/events/{$unapprovedEventId}"); + $hidden->assertStatus(404); + } + + public function test_public_events_api_supports_updated_window_filters(): void + { + $admin = $this->createUserWithToken(Role::ADMINISTRATOR); + $this->actingAs($admin); + + $groupId = $this->createGroup('Updated Window Group', 'https://example.com', 'London', 'Some text', true, true); + $eventId = $this->createEvent($groupId, 'tomorrow', true, true); + + $event = Party::findOrFail($eventId); + $event->timestamps = false; + $event->updated_at = Carbon::parse('2000-01-01 00:00:00')->toDateTimeString(); + $event->save(); + + $token = $this->createPublicApiToken(); + + $response = $this->withHeader('Authorization', 'Bearer ' . $token)->get( + '/api/public/v2/events?updated_start=' . urlencode(Carbon::parse('2010-01-01')->toIso8601String()) + ); + $response->assertSuccessful(); + $json = json_decode($response->getContent(), true); + $this->assertEquals([], $json['data']); + } + + private function createPublicApiToken(array $attributes = []): string + { + $token = 'public_api_token_' . uniqid(); + + ApiClient::factory()->create(array_merge([ + 'token_hash' => hash('sha256', $token), + 'scopes' => ['events:read'], + 'active' => true, + 'expires_at' => null, + ], $attributes)); + + return $token; + } +} From 41e121904757f4579451da5e811d14fc9d11b87f Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Tue, 24 Feb 2026 16:09:56 -0800 Subject: [PATCH 7/7] docs: add public events API documentation Document endpoints, authentication, query parameters, visibility rules, CORS behavior, and artisan commands for client management. Co-Authored-By: Claude Opus 4.6 --- .../Controllers/API/PublicEventController.php | 1 + docs/public-events-api.md | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 docs/public-events-api.md diff --git a/app/Http/Controllers/API/PublicEventController.php b/app/Http/Controllers/API/PublicEventController.php index eba065ddfb..e555b18f71 100644 --- a/app/Http/Controllers/API/PublicEventController.php +++ b/app/Http/Controllers/API/PublicEventController.php @@ -145,4 +145,5 @@ private function applyDateFilters(Builder $query, array $validated): void $query->where('events.updated_at', '<=', $updatedEnd); } } + } diff --git a/docs/public-events-api.md b/docs/public-events-api.md new file mode 100644 index 0000000000..49fd975bc6 --- /dev/null +++ b/docs/public-events-api.md @@ -0,0 +1,48 @@ +# Public Events API + +The public events API is available under `/api/public/v2` and is intended for third-party event ingestion/display. + +## Feature flag + +Enable with: + +`FEATURE__PUBLIC_EVENTS_API=true` + +## Authentication + +All `GET` endpoints require: + +`Authorization: Bearer ` + +Tokens are managed via artisan commands: + +- `php artisan api-clients:create --name="Partner Name"` +- `php artisan api-clients:revoke ` +- `php artisan api-clients:rotate ` + +## Endpoints + +- `GET /api/public/v2/events` +- `GET /api/public/v2/events/{id}` +- `GET /api/public/v2/groups/{id}/events` + +## Query params (`GET /events`) + +- `start` (ISO8601) +- `end` (ISO8601) +- `updated_start` (ISO8601) +- `updated_end` (ISO8601) +- `page` (default `1`) +- `per_page` (default `50`, max `100`) + +## Defaults and visibility rules + +- Defaults to upcoming events (`event_end_utc >= now`). +- Returns only approved events from approved groups. +- Excludes soft-deleted events/groups. +- Public payload intentionally omits `stats`, `network_data`, and `group.networks`. + +## CORS/origin behavior + +- CORS headers are returned for public API routes. +- If an API client has `allowed_origins` configured, requests with a non-matching `Origin` are rejected with `403`.