Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.base
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=
META_TWITTER_IMAGE_ALT=
3 changes: 2 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
META_TWITTER_IMAGE_ALT="$META_TWITTER_IMAGE_ALT"
90 changes: 90 additions & 0 deletions app/Console/Commands/ApiClientsCreate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\Console\Commands;

use App\Models\ApiClient;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Str;

class ApiClientsCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api-clients:create
{--name= : Display name for the integration client}
{--scopes=events:read : Comma-separated scopes}
{--origins= : Comma-separated allowed origins}
{--networks= : Comma-separated allowed network IDs}
{--rate=120 : Requests per minute}
{--expires-at= : Expiration datetime}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a read-only integration API client and print its secret once';

/**
* Execute the console command.
*/
public function handle(): int
{
$name = trim((string) $this->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))));
}
}
43 changes: 43 additions & 0 deletions app/Console/Commands/ApiClientsRevoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace App\Console\Commands;

use App\Models\ApiClient;
use Illuminate\Console\Command;

class ApiClientsRevoke extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api-clients:revoke {id : API client ID}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Revoke an integration API client';

/**
* Execute the console command.
*/
public function handle(): int
{
$client = ApiClient::find($this->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;
}
}
49 changes: 49 additions & 0 deletions app/Console/Commands/ApiClientsRotate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Console\Commands;

use App\Models\ApiClient;
use Illuminate\Console\Command;
use Illuminate\Support\Str;

class ApiClientsRotate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api-clients:rotate {id : API client ID}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Rotate an integration API client token';

/**
* Execute the console command.
*/
public function handle(): int
{
$client = ApiClient::find($this->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;
}
}
149 changes: 149 additions & 0 deletions app/Http/Controllers/API/PublicEventController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Http\Resources\Party as PartyResource;
use App\Models\Group;
use App\Models\Party;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class PublicEventController extends Controller
{
public function listEvents(Request $request): JsonResponse
{
return $this->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);
}
}

}
Loading