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/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; + } +} diff --git a/app/Http/Controllers/API/PublicEventController.php b/app/Http/Controllers/API/PublicEventController.php new file mode 100644 index 0000000000..e555b18f71 --- /dev/null +++ b/app/Http/Controllers/API/PublicEventController.php @@ -0,0 +1,149 @@ +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/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/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/bootstrap/app.php b/bootstrap/app.php index 06da62039a..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) { @@ -85,10 +93,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, ]); 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' => [ 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'); + } +}; 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`. 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 +}); 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; + } +}