+
+
+**Chat.** One channel per source, so chat survives the handover from one show to the next. Timeouts, bans, purge, clear, announcements, slow mode and per-message actions, plus chat commands for moderators. Custom emotes are uploaded by users and approved by staff.
+
+**Programme.** Shows are planned on a drag-and-drop timeline, imported from [pretalx](docs/admin/pretalx-import.md), or created by hand. [Auto mode](docs/admin/auto-mode.md) starts a show when its source comes online and stops it at a hard stop, so nobody has to sit on the button at 2am.
+
+**Archive.** Every source is recorded continuously to object storage. A recording is a time range over that archive, cut in a timeline editor with in and out markers, and rebuilt from the current markers whenever you move them. Published recordings are grouped by year and can require a role to watch.
+
+**Infrastructure.** Edge servers are provisioned on Hetzner Cloud from the panel, get their DNS record, run a generated install script, and are handed viewers by the assignment job. Health checks, viewer counts, capacity and alerts sit on one dashboard.
+
+**Access.** Sign-in goes through OpenID Connect. Roles carry permissions, and shows and recordings can require one. With `AUTH_REQUIRED=false` the public pages are open to guests and only chat stays behind sign-in.
+
+## Screenshots
+
+| | |
+|---|---|
+|  |  |
+| Player, live chat and moderator badges | Programme guide across channels and days |
+|  |  |
+| Archive, one collection per year | Cutting a recording out of the continuous archive |
+|  |  |
+| Capacity, server health and what is on air | Planning the programme on a timeline |
+|  |  |
+| Shows, with stream control per row | Branding, colours and links, applied without a rebuild |
+|  |  |
+| Sources, one per room, each with its own stream key | Sign-in, with what is on air next to it |
+
+The demo content in these screenshots is [Big Buck Bunny](https://peach.blender.org/) (CC BY 3.0, Blender Foundation).
+
+## How it works
+
+```
+OBS / encoder
+ | RTMP, one stream key per source
+SRS ingress ──DVR──> uploader ──> S3 ──> archive playlists ──> recordings
+ |
+ffmpeg ABR ladder (480p / 720p / 1080p, aligned GOPs)
+ |
+origin (nginx + caddy)
+ |
+edge servers (njs verifies playback tokens)
+ |
+viewers
+```
+
+The Laravel app never carries video. It hands out signed playback tokens, assigns viewers to an edge, and proxies playlists so a viewer only ever talks to its own domain. Segments come from the edges. Archive segments come from the bucket as presigned URLs, which is why a recording playlist is rendered per request rather than stored.
+
+## Stack
+
+Laravel 12 on PHP 8.2, Inertia 2 with Vue 3 and Tailwind 4, Vidstack and hls.js in the player, Reverb or Pusher for websockets, Horizon on Redis for queues, MySQL or PostgreSQL, S3-compatible object storage, SRS and ffmpeg for the video path, Hetzner Cloud for servers.
+
+## Running it locally
+
+You need PHP 8.2+, Composer, Node 20+, a database, Redis or Valkey, ffmpeg, and Docker if you want the full video path.
+
+```bash
+composer install
+npm install
+cp .env.example .env
+php artisan key:generate
+php artisan migrate --seed
+npm run dev
+php artisan queue:work
+php artisan reverb:start
+```
+
+Video is optional for UI work, and comes in two flavours:
+
+```bash
+# fake channels written straight to disk, no Docker
+php artisan db:seed --class=DevStreamChannelsSeeder
+./scripts/dev-streams.sh # then set DEV_STREAMS=true in .env
+
+# the real path: SRS, ABR ladder, origin, edge, S3, DVR
+./scripts/dev-stack.sh up
+```
+
+[docs/dev-stack.md](docs/dev-stack.md) covers both, including the switches that keep five channels running on a laptop without melting it.
+
+## Configuration
+
+The settings that matter live in `.env`:
+
+| Variable | What it controls |
+|---|---|
+| `OIDC_URL`, `OIDC_CLIENT_ID`, `OIDC_SECRET` | Identity provider |
+| `AUTH_REQUIRED` | Whether guests can watch |
+| `CHAT_ENABLED`, `CHAT_*` | Chat, rate limits, slow mode, link handling |
+| `HLS_VIEWER_SECRET`, `HLS_EMBED_SECRET` | Playback tokens the edges verify |
+| `AWS_*`, `DVR_AWS_*` | Archive and DVR buckets |
+| `HETZNER_TOKEN`, `DNS_*` | Server provisioning and DNS records |
+| `STREAM_SYSTEM_STREAMKEY` | Shared secret between the app and the video stack |
+
+Branding is not an env var. Convention name, copy, footer links, logo, login background and accent colour are stored in the `branding_settings` table and edited at `/manage` > Settings, or scripted:
+
+```bash
+php artisan branding:set convention_name="Example Con" primary_color="#7c5cff"
+```
+
+The accent colour is applied as CSS custom properties at runtime, so changing it takes effect without a rebuild.
+
+## Deployment
+
+The root `Dockerfile` builds the app image. `docker/` holds the images for the video path: origin SRS, origin and edge nginx and caddy, the ABR transcoder, and the DVR uploader. Queues run under Horizon, websockets under Reverb, and the scheduler needs `php artisan schedule:run` every minute.
+
+## Documentation
+
+- [docs/dev-stack.md](docs/dev-stack.md): the local video stack
+- [docs/admin/pretalx-import.md](docs/admin/pretalx-import.md): importing the programme
+- [docs/admin/auto-mode.md](docs/admin/auto-mode.md): starting and stopping shows without a human
+- [docs/dvr-archive-plan.md](docs/dvr-archive-plan.md): how the archive and cutting work
+
+## Contributing
+
+Issues and pull requests are welcome. For anything larger, or for help running this at your own convention, contact @Thiritin on Telegram.
+
+## Licence
+
+GPL-3.0. See [LICENSE](LICENSE).
diff --git a/app/Console/Commands/BrandingSetCommand.php b/app/Console/Commands/BrandingSetCommand.php
new file mode 100644
index 0000000..8df8edf
--- /dev/null
+++ b/app/Console/Commands/BrandingSetCommand.php
@@ -0,0 +1,125 @@
+ Settings. This command is the same write from a shell,
+ * so provisioning can set it once without a second source of truth that would
+ * silently stop applying the first time somebody saves the form.
+ */
+class BrandingSetCommand extends Command
+{
+ protected $signature = 'branding:set
+ {pairs?* : key=value pairs, e.g. primary_color=#0e7490 site_name="My Con"}
+ {--list : Show every key with its current value and shipped default}';
+
+ /**
+ * Repeater fields take JSON on the command line, e.g.
+ * footer_links=\'[{"label":"Privacy","url":"https://example.org/privacy"}]\'
+ */
+ private const JSON_TYPES = ['links'];
+
+ protected $description = 'Set branding values (name, copy, links, accent colour) from the command line';
+
+ public function handle(Settings $settings): int
+ {
+ $fields = collect($settings->groups())
+ ->flatMap(fn (array $group) => $group['fields'])
+ ->keyBy('key');
+
+ if ($this->option('list') || $this->argument('pairs') === []) {
+ $this->table(
+ ['Key', 'Current', 'Default'],
+ $fields->map(fn (array $field) => [
+ $field['key'],
+ $this->truncate($field['value']),
+ $this->truncate($field['default']),
+ ])->values()->all(),
+ );
+
+ return self::SUCCESS;
+ }
+
+ $parsed = [];
+
+ foreach ($this->argument('pairs') as $pair) {
+ if (! str_contains($pair, '=')) {
+ $this->error("Expected key=value, got \"{$pair}\".");
+
+ return self::FAILURE;
+ }
+
+ [$key, $value] = explode('=', $pair, 2);
+ $key = trim($key);
+
+ if (! $fields->has($key)) {
+ $this->error("Unknown branding key \"{$key}\". Run with --list to see them all.");
+
+ return self::FAILURE;
+ }
+
+ if (in_array($fields[$key]['type'], self::JSON_TYPES, true)) {
+ $decoded = json_decode($value, true);
+
+ if (! is_array($decoded)) {
+ $this->error("\"{$key}\" takes a JSON list, e.g. ".
+ '\'[{"label":"Privacy","url":"https://example.org/privacy"}]\'');
+
+ return self::FAILURE;
+ }
+
+ $value = $decoded;
+ }
+
+ $parsed[$key] = $value;
+ }
+
+ // The same rules the form applies, so a scripted write cannot store
+ // something the panel would have rejected.
+ $rules = collect($settings->rules())
+ ->only(array_map(fn ($key) => 'values.'.$key, array_keys($parsed)))
+ ->all();
+
+ $validator = Validator::make(
+ ['values' => $parsed],
+ $rules,
+ [],
+ $settings->attributes(),
+ );
+
+ if ($validator->fails()) {
+ foreach ($validator->errors()->all() as $error) {
+ $this->error($error);
+ }
+
+ return self::FAILURE;
+ }
+
+ $settings->save($parsed);
+
+ foreach (array_keys($parsed) as $key) {
+ $stored = BrandingSetting::where('key', $key)->exists();
+
+ $this->line($stored
+ ? " '
- .htmlspecialchars($record->getRtmpServerUrl()).
- ''
- );
- })
- ->helperText('Click to copy → OBS Settings → Stream → Server'),
- Forms\Components\Placeholder::make('obs_stream_key_display')
- ->label('OBS Stream Key')
- ->content(function (?Source $record) {
- if (! $record || ! $record->stream_key) {
- return 'Will be generated on save';
- }
-
- return new \Illuminate\Support\HtmlString(
- ''
- .htmlspecialchars($record->getObsStreamKey()).
- ''
- );
- })
- ->helperText('Click to copy → OBS Settings → Stream → Stream Key'),
- ]),
- ]);
- }
-
- public static function table(Table $table): Table
- {
- return $table
- ->columns([
- BadgeColumn::make('status')
- ->label('Status')
- ->getStateUsing(fn ($record) => $record->status->value)
- ->colors([
- 'success' => SourceStatusEnum::ONLINE->value,
- 'gray' => SourceStatusEnum::OFFLINE->value,
- 'danger' => SourceStatusEnum::ERROR->value,
- ])
- ->icons([
- 'heroicon-o-signal' => SourceStatusEnum::ONLINE->value,
- 'heroicon-o-signal-slash' => SourceStatusEnum::OFFLINE->value,
- 'heroicon-o-exclamation-triangle' => SourceStatusEnum::ERROR->value,
- ]),
- TextColumn::make('name')
- ->searchable()
- ->sortable()
- ->weight('bold'),
- TextColumn::make('slug')
- ->label('Stream Name')
- ->badge()
- ->color('info')
- ->copyable()
- ->searchable(),
- TextColumn::make('priority')
- ->label('Priority')
- ->sortable()
- ->badge()
- ->color('primary'),
- TextColumn::make('shows_count')
- ->label('Total Shows')
- ->counts('shows')
- ->sortable(),
- TextColumn::make('live_shows_count')
- ->label('Live Now')
- ->getStateUsing(fn ($record) => $record->liveShows()->count())
- ->badge()
- ->color(fn ($state) => $state > 0 ? 'success' : 'gray'),
- TextColumn::make('created_at')
- ->dateTime()
- ->sortable()
- ->toggleable(isToggledHiddenByDefault: true),
- TextColumn::make('updated_at')
- ->dateTime()
- ->sortable()
- ->toggleable(isToggledHiddenByDefault: true),
- ])
- ->defaultSort('priority', 'desc')
- ->filters([
- Tables\Filters\SelectFilter::make('status')
- ->label('Status')
- ->options([
- SourceStatusEnum::ONLINE->value => 'Online',
- SourceStatusEnum::OFFLINE->value => 'Offline',
- SourceStatusEnum::ERROR->value => 'Error',
- ])
- ->placeholder('All statuses'),
- ])
- ->actions([
- Tables\Actions\Action::make('updateStatus')
- ->label('Update Status')
- ->icon('heroicon-o-arrow-path')
- ->color('warning')
- ->form([
- Select::make('status')
- ->label('New Status')
- ->options([
- SourceStatusEnum::ONLINE->value => 'Online',
- SourceStatusEnum::OFFLINE->value => 'Offline',
- SourceStatusEnum::ERROR->value => 'Error',
- ])
- ->default(fn ($record) => $record->status->value)
- ->required()
- ->native(false),
- ])
- ->action(function ($record, array $data) {
- $record->update(['status' => $data['status']]);
-
- // Observer will automatically broadcast the status change event
-
- Notification::make()
- ->title('Status updated')
- ->body("Source '{$record->name}' status has been updated to {$data['status']}.")
- ->success()
- ->send();
- }),
- Tables\Actions\EditAction::make(),
- Tables\Actions\DeleteAction::make()
- ->before(function ($record) {
- if ($record->liveShows()->exists()) {
- Notification::make()
- ->title('Cannot delete source')
- ->body('This source has active live shows.')
- ->danger()
- ->send();
-
- return false;
- }
- }),
- ])
- ->bulkActions([
- Tables\Actions\BulkActionGroup::make([
- Tables\Actions\BulkAction::make('updateStatus')
- ->label('Update Status')
- ->icon('heroicon-o-arrow-path')
- ->form([
- Select::make('status')
- ->label('New Status')
- ->options([
- SourceStatusEnum::ONLINE->value => 'Online',
- SourceStatusEnum::OFFLINE->value => 'Offline',
- SourceStatusEnum::ERROR->value => 'Error',
- ])
- ->required()
- ->native(false),
- ])
- ->action(function ($records, array $data) {
- $records->each(function ($record) use ($data) {
- $record->update(['status' => $data['status']]);
- // Observer will automatically broadcast the status change event
- });
-
- Notification::make()
- ->title('Status updated')
- ->body('The selected sources have been updated.')
- ->success()
- ->send();
- })
- ->deselectRecordsAfterCompletion(),
- Tables\Actions\DeleteBulkAction::make()
- ->before(function ($records) {
- foreach ($records as $record) {
- if ($record->liveShows()->exists()) {
- Notification::make()
- ->title('Cannot delete sources')
- ->body('One or more sources have active live shows.')
- ->danger()
- ->send();
-
- return false;
- }
- }
- }),
- ]),
- ])
- ->poll('10s');
- }
-
- public static function getRelations(): array
- {
- return [
- RelationManagers\ShowsRelationManager::class,
- ];
- }
-
- public static function getPages(): array
- {
- return [
- 'index' => Pages\ListSources::route('/'),
- 'create' => Pages\CreateSource::route('/create'),
- 'edit' => Pages\EditSource::route('/{record}/edit'),
- ];
- }
-
- public static function getNavigationBadge(): ?string
- {
- $onlineCount = static::getModel()::where('status', SourceStatusEnum::ONLINE)->count();
- return $onlineCount > 0 ? (string) $onlineCount : null;
- }
-
- public static function getNavigationBadgeColor(): ?string
- {
- return 'success';
- }
-}
\ No newline at end of file
diff --git a/app/Filament/Resources/SourceResource/Pages/CreateSource.php b/app/Filament/Resources/SourceResource/Pages/CreateSource.php
deleted file mode 100644
index 28bd2bf..0000000
--- a/app/Filament/Resources/SourceResource/Pages/CreateSource.php
+++ /dev/null
@@ -1,11 +0,0 @@
-label('Regenerate Stream Key')
- ->icon('heroicon-o-arrow-path')
- ->color('warning')
- ->requiresConfirmation()
- ->modalHeading('Regenerate Stream Key?')
- ->modalDescription('This will invalidate the current stream key. Any active streams will be disconnected.')
- ->action(function () {
- $newKey = Str::random(32);
- $this->record->stream_key = $newKey;
- $this->record->save();
-
- // Refresh the form to show the new key
- $this->fillForm();
-
- Notification::make()
- ->title('Stream key regenerated')
- ->body('The new stream key has been saved and is now active.')
- ->success()
- ->send();
- }),
- Actions\DeleteAction::make(),
- ];
- }
-}
diff --git a/app/Filament/Resources/SourceResource/Pages/ListSources.php b/app/Filament/Resources/SourceResource/Pages/ListSources.php
deleted file mode 100644
index 1cc6144..0000000
--- a/app/Filament/Resources/SourceResource/Pages/ListSources.php
+++ /dev/null
@@ -1,19 +0,0 @@
-schema([
- Forms\Components\TextInput::make('title')
- ->required()
- ->maxLength(255),
- ]);
- }
-
- public function table(Table $table): Table
- {
- return $table
- ->recordTitleAttribute('title')
- ->columns([
- Tables\Columns\TextColumn::make('title')
- ->searchable()
- ->sortable(),
- Tables\Columns\BadgeColumn::make('status')
- ->colors([
- 'success' => 'live',
- 'warning' => 'scheduled',
- 'gray' => 'ended',
- 'danger' => 'cancelled',
- ]),
- Tables\Columns\TextColumn::make('scheduled_start')
- ->dateTime()
- ->sortable(),
- Tables\Columns\TextColumn::make('viewer_count')
- ->label('Viewers')
- ->sortable(),
- ])
- ->filters([
- //
- ])
- ->headerActions([
- Tables\Actions\CreateAction::make(),
- ])
- ->actions([
- Tables\Actions\EditAction::make(),
- Tables\Actions\DeleteAction::make(),
- ])
- ->bulkActions([
- Tables\Actions\BulkActionGroup::make([
- Tables\Actions\DeleteBulkAction::make(),
- ]),
- ]);
- }
-}
diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php
deleted file mode 100644
index 0ad9b90..0000000
--- a/app/Filament/Resources/UserResource.php
+++ /dev/null
@@ -1,104 +0,0 @@
-schema([
- TextInput::make('sub')
- ->disabled()
- ->required(),
-
- TextInput::make('name')
- ->disabled()
- ->required(),
-
- TextInput::make('reg_id')
- ->disabled()
- ->integer(),
-
- Select::make('server_id')
- ->relationship('server', 'hostname',
- fn (Builder $query) => $query
- ->where('type', ServerTypeEnum::EDGE)
- ->where('status', ServerStatusEnum::ACTIVE))
- ->nullable(),
-
- Placeholder::make('updated_at')
- ->label('Last Modified Date')
- ->content(fn (?User $record): string => $record?->updated_at?->diffForHumans() ?? '-'),
-
- Placeholder::make('created_at')
- ->label('Created Date')
- ->content(fn (?User $record): string => $record?->created_at?->diffForHumans() ?? '-'),
- ]);
- }
-
- public static function table(Table $table): Table
- {
- return $table
- ->columns([
- TextColumn::make('sub'),
-
- TextColumn::make('name')
- ->searchable()
- ->sortable(),
-
- TextColumn::make('reg_id'),
- ]);
- }
-
- public static function getRelations(): array
- {
- return [
- RolesRelationManager::class,
- MessagesRelationManager::class,
- ];
- }
-
- public static function getPages(): array
- {
- return [
- 'index' => Pages\ListUsers::route('/'),
- 'create' => Pages\CreateUser::route('/create'),
- 'edit' => Pages\EditUser::route('/{record}/edit'),
- ];
- }
-
- public static function getGloballySearchableAttributes(): array
- {
- return ['name'];
- }
-}
diff --git a/app/Filament/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Resources/UserResource/Pages/CreateUser.php
deleted file mode 100644
index 78a3894..0000000
--- a/app/Filament/Resources/UserResource/Pages/CreateUser.php
+++ /dev/null
@@ -1,11 +0,0 @@
-schema([
- Select::make('user_id')
- ->relationship('user', 'name')
- ->searchable(),
-
- TextInput::make('message')
- ->required(),
-
- Checkbox::make('is_command'),
-
- Placeholder::make('created_at')
- ->label('Created Date')
- ->content(fn (?Message $record): string => $record?->created_at?->diffForHumans() ?? '-'),
-
- Placeholder::make('updated_at')
- ->label('Last Modified Date')
- ->content(fn (?Message $record): string => $record?->updated_at?->diffForHumans() ?? '-'),
- ]);
- }
-
- public function table(Table $table): Table
- {
- return $table
- ->columns([
- TextColumn::make('user.name')
- ->searchable()
- ->sortable(),
-
- TextColumn::make('message'),
-
- TextColumn::make('is_command'),
- ]);
- }
-}
diff --git a/app/Filament/Resources/UserResource/RelationManagers/RolesRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/RolesRelationManager.php
deleted file mode 100644
index 772f6c5..0000000
--- a/app/Filament/Resources/UserResource/RelationManagers/RolesRelationManager.php
+++ /dev/null
@@ -1,98 +0,0 @@
-schema([
- Forms\Components\Select::make('role_id')
- ->label('Role')
- ->options(Role::ordered()->pluck('name', 'id'))
- ->required(),
- ]);
- }
-
- public function table(Table $table): Table
- {
- return $table
- ->columns([
- Tables\Columns\TextColumn::make('name')
- ->label('Role')
- ->weight('bold')
- ->searchable()
- ->sortable(),
- Tables\Columns\TextColumn::make('slug')
- ->badge()
- ->color('gray'),
- Tables\Columns\ColorColumn::make('chat_color')
- ->label('Chat Color')
- ->copyable()
- ->copyMessage('Color copied'),
- Tables\Columns\TextColumn::make('priority')
- ->badge()
- ->color(fn ($state) => match (true) {
- $state >= 100 => 'danger',
- $state >= 90 => 'warning',
- $state >= 50 => 'info',
- default => 'gray'
- }),
- Tables\Columns\ToggleColumn::make('assigned_at_login')
- ->label('Login Sync')
- ->disabled(),
- ])
- ->filters([])
- ->headerActions([
- Tables\Actions\AttachAction::make()
- ->preloadRecordSelect()
- ->form(fn (Tables\Actions\AttachAction $action): array => [
- Forms\Components\Select::make('recordId')
- ->label('Role')
- ->options(Role::ordered()->pluck('name', 'id'))
- ->required()
- ->searchable()
- ->placeholder('Select a role'),
- ])
- ->successNotification(
- Notification::make()
- ->success()
- ->title('Role assigned')
- ->body('The role has been assigned to the user.')
- ),
- ])
- ->actions([
- Tables\Actions\DetachAction::make()
- ->successNotification(
- Notification::make()
- ->success()
- ->title('Role removed')
- ->body('The role has been removed from the user.')
- )
- ->modalHeading('Remove Role')
- ->modalDescription('Are you sure you want to remove this role from the user?'),
- ])
- ->bulkActions([
- Tables\Actions\BulkActionGroup::make([
- Tables\Actions\DetachBulkAction::make()
- ->modalHeading('Remove Selected Roles')
- ->modalDescription('Are you sure you want to remove the selected roles from this user?'),
- ]),
- ])
- ->defaultSort('priority', 'desc')
- ->paginated([10, 25, 50]);
- }
-}
diff --git a/app/Filament/Widgets/Capacity.php b/app/Filament/Widgets/Capacity.php
deleted file mode 100644
index 2495c5b..0000000
--- a/app/Filament/Widgets/Capacity.php
+++ /dev/null
@@ -1,30 +0,0 @@
-where('type', ServerTypeEnum::EDGE)->sum('max_clients');
- $maxClientsProvisioning = Server::where('status', ServerStatusEnum::PROVISIONING)->where('type', ServerTypeEnum::EDGE)->sum('max_clients');
- $waitingUsers = User::whereNull('server_id')->count();
-
- return [
- Card::make('Max clients', $maxClients),
- Card::make('Booting Capacity', $maxClientsProvisioning),
- Card::make('Waiting Users', $waitingUsers),
- ];
- }
-}
diff --git a/app/Filament/Widgets/ServerActive.php b/app/Filament/Widgets/ServerActive.php
deleted file mode 100644
index ab65ceb..0000000
--- a/app/Filament/Widgets/ServerActive.php
+++ /dev/null
@@ -1,26 +0,0 @@
-where('type', ServerTypeEnum::EDGE)->select(['status', \DB::raw('COUNT(servers.id) AS count')])->get();
- $widget = [];
- foreach ($server as $s) {
- $widget[] = Card::make('Edge Server '.$s->status->value, $s->count);
- }
-
- return $widget;
- }
-}
diff --git a/app/Filament/Widgets/ViewCountChart.php b/app/Filament/Widgets/ViewCountChart.php
deleted file mode 100644
index cbe1e29..0000000
--- a/app/Filament/Widgets/ViewCountChart.php
+++ /dev/null
@@ -1,77 +0,0 @@
- 'Saturday',
- '03-09-2023' => 'Sunday',
- '04-09-2023' => 'Monday',
- '05-09-2023' => 'Tuesday',
- '06-09-2023' => 'Wednesday',
- '07-09-2023' => 'Thursday',
- '08-09-2023' => 'Friday',
- ];
- $datalist = [];
- foreach ($days as $k => $v) {
- $model = Trend::model(ViewCount::class)
- ->between(
- start: \Illuminate\Support\Carbon::parse($k),
- end: \Illuminate\Support\Carbon::parse($k)->endOfDay(),
- )
- ->perHour()
- ->average('count');
- $datalist[] = [
- 'label' => $v,
- 'data' => $model->map(fn (TrendValue $value) => $value->aggregate),
- ];
- }
-
- return [
- 'datasets' => $datalist,
- 'labels' => [
- '00:00',
- '01:00',
- '02:00',
- '03:00',
- '04:00',
- '05:00',
- '06:00',
- '07:00',
- '08:00',
- '09:00',
- '10:00',
- '11:00',
- '12:00',
- '13:00',
- '14:00',
- '15:00',
- '16:00',
- '17:00',
- '18:00',
- '19:00',
- '20:00',
- '21:00',
- '22:00',
- '23:00',
- ],
- ];
- }
-}
diff --git a/app/Helpers/IpSubnetHelper.php b/app/Helpers/IpSubnetHelper.php
index e31cf45..6923b8d 100644
--- a/app/Helpers/IpSubnetHelper.php
+++ b/app/Helpers/IpSubnetHelper.php
@@ -6,10 +6,9 @@ class IpSubnetHelper
{
/**
* Check if an IP address is within a subnet
- *
- * @param string $ip The IP address to check
- * @param string $subnet The subnet in CIDR notation (e.g., 192.168.1.0/24 or 2001:db8::/64)
- * @return bool
+ *
+ * @param string $ip The IP address to check
+ * @param string $subnet The subnet in CIDR notation (e.g., 192.168.1.0/24 or 2001:db8::/64)
*/
public static function isIpInSubnet(string $ip, string $subnet): bool
{
@@ -18,7 +17,7 @@ public static function isIpInSubnet(string $ip, string $subnet): bool
}
// Check if subnet contains a slash for CIDR notation
- if (!str_contains($subnet, '/')) {
+ if (! str_contains($subnet, '/')) {
return false;
}
@@ -47,8 +46,8 @@ public static function isIpInSubnet(string $ip, string $subnet): bool
private static function isIpv4InSubnet(string $ip, string $subnetAddress, int $prefixLength): bool
{
// Validate IP addresses
- if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
- !filter_var($subnetAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
+ ! filter_var($subnetAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
@@ -74,8 +73,8 @@ private static function isIpv4InSubnet(string $ip, string $subnetAddress, int $p
private static function isIpv6InSubnet(string $ip, string $subnetAddress, int $prefixLength): bool
{
// Validate IP addresses
- if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ||
- !filter_var($subnetAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+ if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ||
+ ! filter_var($subnetAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}
@@ -108,7 +107,7 @@ private static function isIpv6InSubnet(string $ip, string $subnetAddress, int $p
$mask = 0xFF << (8 - $bitsRemaining);
$ipByte = ord($ipBinary[$bytesToCompare]);
$subnetByte = ord($subnetBinary[$bytesToCompare]);
-
+
if (($ipByte & $mask) !== ($subnetByte & $mask)) {
return false;
}
@@ -116,4 +115,4 @@ private static function isIpv6InSubnet(string $ip, string $subnetAddress, int $p
return true;
}
-}
\ No newline at end of file
+}
diff --git a/app/Http/Controllers/Api/CommandController.php b/app/Http/Controllers/Api/CommandController.php
index eb9c34c..3eeee4a 100644
--- a/app/Http/Controllers/Api/CommandController.php
+++ b/app/Http/Controllers/Api/CommandController.php
@@ -26,24 +26,25 @@ public function execute(Request $request)
{
$request->validate([
'command' => 'required|string|max:1000',
+ 'source_id' => 'nullable|integer|exists:sources,id',
]);
$user = Auth::user();
$commandInput = $request->input('command');
// Check if user is timed out (unless it's the help command)
- if (!str_starts_with(trim($commandInput), '/help')) {
+ if (! str_starts_with(trim($commandInput), '/help')) {
$activeTimeout = Timeout::where('user_id', $user->id)
->where('expires_at', '>', now())
->first();
-
+
if ($activeTimeout) {
$remainingTime = now()->diffInSeconds($activeTimeout->expires_at);
$message = "You are timed out for {$remainingTime} more seconds";
if ($activeTimeout->reason) {
$message .= " (Reason: {$activeTimeout->reason})";
}
-
+
return response()->json([
'success' => false,
'error' => $message,
@@ -57,9 +58,10 @@ public function execute(Request $request)
}
// Rate limiting for commands
- $key = 'command-execute:' . $user->id;
+ $key = 'command-execute:'.$user->id;
if (RateLimiter::tooManyAttempts($key, 10)) {
$seconds = RateLimiter::availableIn($key);
+
return response()->json([
'success' => false,
'error' => "Too many commands. Please wait {$seconds} seconds.",
@@ -70,8 +72,8 @@ public function execute(Request $request)
// Find the command
$command = $this->registry->findByInput($commandInput);
-
- if (!$command) {
+
+ if (! $command) {
return response()->json([
'success' => false,
'error' => 'Command not found. Type /help for available commands.',
@@ -83,8 +85,13 @@ public function execute(Request $request)
$command->setRawInput($commandInput);
}
+ // Commands act on the chat they were typed in
+ if (method_exists($command, 'setSourceId')) {
+ $command->setSourceId($request->integer('source_id') ?: null);
+ }
+
// Check authorization
- if (!$command->authorize($user)) {
+ if (! $command->authorize($user)) {
return response()->json([
'success' => false,
'error' => 'You do not have permission to use this command.',
@@ -147,7 +154,7 @@ public function execute(Request $request)
]);
// Send error feedback to user
- $command->feedback($user, 'Command failed: ' . $e->getMessage(), 'error');
+ $command->feedback($user, 'Command failed: '.$e->getMessage(), 'error');
return response()->json([
'success' => false,
@@ -230,13 +237,13 @@ public function help(Request $request)
$command = $this->registry->get($commandName);
- if (!$command) {
+ if (! $command) {
return response()->json([
'error' => 'Command not found.',
], 404);
}
- if (!$command->authorize($user)) {
+ if (! $command->authorize($user)) {
return response()->json([
'error' => 'You do not have permission to view this command.',
], 403);
@@ -247,4 +254,4 @@ public function help(Request $request)
'examples' => method_exists($command, 'examples') ? $command->examples() : [],
]);
}
-}
\ No newline at end of file
+}
diff --git a/app/Http/Controllers/Api/FfmpegCallbackController.php b/app/Http/Controllers/Api/FfmpegCallbackController.php
index ed81cad..b9f6f95 100644
--- a/app/Http/Controllers/Api/FfmpegCallbackController.php
+++ b/app/Http/Controllers/Api/FfmpegCallbackController.php
@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Api;
-use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
@@ -16,29 +16,29 @@ public function onPublish(Request $request)
{
$app = $request->input('app');
$stream = $request->input('stream');
-
+
// Only process 'live' app (transcoded outputs)
if ($app !== 'live') {
return response()->json(['code' => 0]);
}
-
+
// Skip quality variants (only process base streams)
if (preg_match('/_(fhd|hd|sd|ld)$/', $stream)) {
return response()->json(['code' => 0]);
}
-
+
Log::info('Starting FFmpeg HLS generation', [
'app' => $app,
'stream' => $stream,
]);
-
+
// Signal the FFmpeg manager to check for new streams
// Or directly start FFmpeg process here if preferred
$this->signalFfmpegManager();
-
+
return response()->json(['code' => 0]);
}
-
+
/**
* Handle stream unpublish event - stop FFmpeg HLS generation
*/
@@ -46,28 +46,28 @@ public function onUnpublish(Request $request)
{
$app = $request->input('app');
$stream = $request->input('stream');
-
+
// Only process 'live' app
if ($app !== 'live') {
return response()->json(['code' => 0]);
}
-
+
// Skip quality variants
if (preg_match('/_(fhd|hd|sd|ld)$/', $stream)) {
return response()->json(['code' => 0]);
}
-
+
Log::info('Stopping FFmpeg HLS generation', [
'app' => $app,
'stream' => $stream,
]);
-
+
// Signal the FFmpeg manager to check for removed streams
$this->signalFfmpegManager();
-
+
return response()->json(['code' => 0]);
}
-
+
/**
* Signal the FFmpeg manager to recheck streams
*/
@@ -76,8 +76,8 @@ private function signalFfmpegManager()
// Touch a file that the manager watches, or send a signal
$signalFile = '/var/www/html/hls/check_streams';
@touch($signalFile);
-
+
// Alternative: Send HTTP request to FFmpeg manager if it has an API
// Or use Redis pub/sub for real-time notifications
}
-}
\ No newline at end of file
+}
diff --git a/app/Http/Controllers/Api/HlsSessionController.php b/app/Http/Controllers/Api/HlsSessionController.php
index bf09e3c..8ae1375 100644
--- a/app/Http/Controllers/Api/HlsSessionController.php
+++ b/app/Http/Controllers/Api/HlsSessionController.php
@@ -6,9 +6,9 @@
use App\Models\Server;
use App\Models\Source;
use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
class HlsSessionController extends Controller
{
@@ -22,25 +22,26 @@ public function auth(Request $request)
$originalUri = $request->header('X-Original-URI');
$realIp = $request->header('X-Real-IP', $request->ip());
$edgeServerId = $request->header('X-Edge-Server');
-
+
$userId = null;
$userName = null;
$user = null;
$hlsContext = null;
-
+
// Parse stream slug from URI first to create cache keys
// Format: /live/{slug}_quality.m3u8 or /live/{slug}_quality_segment.ts or /live/{slug}_master.m3u8
- if (!preg_match('#^/live/([^/_]+?)(?:_(?:master|fhd|hd|sd|ld))?(?:\.|_)#', $originalUri, $slugMatches)) {
+ if (! preg_match('#^/live/([^/_]+?)(?:_(?:master|fhd|hd|sd|ld))?(?:\.|_)#', $originalUri, $slugMatches)) {
Log::warning('Invalid HLS URI format', ['uri' => $originalUri]);
+
return response()->json(['error' => 'Invalid URI'], 403);
}
-
+
$streamSlug = $slugMatches[1];
-
+
// Extract HLS context if present (from SRS edge server)
if (preg_match('/[?&]hls_ctx=([^&]+)/', $originalUri, $ctxMatches)) {
$hlsContext = $ctxMatches[1];
-
+
// Check if we have stored user info for this HLS context
$storedUserInfo = Cache::get("hls_context:{$hlsContext}");
if ($storedUserInfo) {
@@ -48,12 +49,12 @@ public function auth(Request $request)
$userName = $storedUserInfo['user_name'];
}
}
-
+
// Check for session ID or streamkey in the URL parameters
if (preg_match('/[?&](session_id|streamkey)=([^&]+)/', $originalUri, $matches)) {
$paramName = $matches[1];
$tokenValue = $matches[2];
-
+
// Check if it's the system streamkey (for thumbnails, monitoring, etc.)
$systemStreamkey = config('stream.system_streamkey');
if ($paramName === 'streamkey' && $systemStreamkey && $tokenValue === $systemStreamkey) {
@@ -61,17 +62,17 @@ public function auth(Request $request)
return response('', 200)
->header('X-Session-Id', 'system');
}
-
+
// Check if it's a user's streamkey
if ($paramName === 'streamkey' || $paramName === 'session_id') {
// Cache user lookup for 5 minutes to avoid database queries
- $user = Cache::remember("user:streamkey:{$tokenValue}", 300, function() use ($tokenValue) {
+ $user = Cache::remember("user:streamkey:{$tokenValue}", 300, function () use ($tokenValue) {
return \App\Models\User::where('streamkey', $tokenValue)->first();
});
if ($user) {
$userId = $user->id;
$userName = $user->name;
-
+
// Store user info with HLS context for segment requests
if ($hlsContext) {
Cache::put("hls_context:{$hlsContext}", [
@@ -80,7 +81,7 @@ public function auth(Request $request)
'streamkey' => $tokenValue,
], now()->addHours(2));
}
-
+
// Also store by IP as fallback
$sessionKey = "hls_user:{$realIp}:{$streamSlug}";
Cache::put($sessionKey, [
@@ -90,7 +91,7 @@ public function auth(Request $request)
], now()->addMinutes(10));
}
}
- } else if (!$userId) {
+ } elseif (! $userId) {
// For segment requests without streamkey and no hls_ctx match, check cache by IP+stream
$sessionKey = "hls_user:{$realIp}:{$streamSlug}";
$storedUserInfo = Cache::get($sessionKey);
@@ -101,34 +102,36 @@ public function auth(Request $request)
Cache::put($sessionKey, $storedUserInfo, now()->addMinutes(10));
}
}
-
+
// Check if source exists and is online (cached for 10 seconds)
- $source = Cache::remember("source:slug:{$streamSlug}", 10, function() use ($streamSlug) {
+ $source = Cache::remember("source:slug:{$streamSlug}", 10, function () use ($streamSlug) {
return Source::where('slug', $streamSlug)->first();
});
-
- if (!$source) {
+
+ if (! $source) {
Log::warning('Source not found for HLS request', ['slug' => $streamSlug]);
+
return response()->json(['error' => 'Stream not found'], 404);
}
-
+
if ($source->status !== \App\Enum\SourceStatusEnum::ONLINE) {
Log::info('Source offline for HLS request', [
'slug' => $streamSlug,
'status' => $source->status->value,
]);
+
return response()->json(['error' => 'Stream offline'], 404);
}
-
+
// Create or retrieve session ID for this viewer
$sessionId = $this->getOrCreateSession($realIp, $streamSlug, $edgeServerId, $userId);
-
+
// Return success with session ID
return response()
->json(['status' => 'ok'])
->header('X-Session-Id', $sessionId);
}
-
+
/**
* Handle heartbeat from edge server with viewer counts
*/
@@ -140,39 +143,40 @@ public function heartbeat(Request $request)
'streams' => 'array',
'timestamp' => 'required|date',
]);
-
+
$serverId = $request->input('server_id');
$viewerCount = $request->input('viewer_count');
$streams = $request->input('streams', []);
-
+
Log::info('Edge server heartbeat', [
'server_id' => $serverId,
'viewer_count' => $viewerCount,
'streams' => $streams,
]);
-
+
// Find server by hostname or hetzner_id
$server = Server::where('hostname', $serverId)
->orWhere('hetzner_id', $serverId)
->first();
-
- if (!$server) {
+
+ if (! $server) {
// Try to find by container name for local development
if (str_contains($serverId, 'docker')) {
$server = Server::where('hetzner_id', 'manual')
->where('type', \App\Enum\ServerTypeEnum::EDGE)
->first();
}
-
- if (!$server) {
+
+ if (! $server) {
Log::warning('Unknown edge server in heartbeat', ['server_id' => $serverId]);
+
return response()->json(['error' => 'Unknown server'], 404);
}
}
-
+
// Update server viewer count
$server->updateViewerCount($viewerCount);
-
+
// Store stream-specific viewer counts in cache
foreach ($streams as $streamSlug => $count) {
Cache::put(
@@ -181,12 +185,12 @@ public function heartbeat(Request $request)
now()->addMinutes(2)
);
}
-
+
// Calculate total viewers across all edges for each stream
foreach ($streams as $streamSlug => $count) {
$this->updateStreamViewerCount($streamSlug);
}
-
+
return response()->json([
'status' => 'ok',
'server' => [
@@ -195,36 +199,36 @@ public function heartbeat(Request $request)
],
]);
}
-
+
/**
* Get or create a session for a viewer
*/
private function getOrCreateSession($ip, $streamSlug, $edgeServerId, $userId = null)
{
// Generate session key - include user ID if available for unique sessions per user
- $sessionKey = $userId
+ $sessionKey = $userId
? "hls_session:user:{$userId}:{$streamSlug}:{$edgeServerId}"
: "hls_session:{$ip}:{$streamSlug}:{$edgeServerId}";
-
+
// Check cache for existing session
$sessionId = Cache::get($sessionKey);
-
- if (!$sessionId) {
+
+ if (! $sessionId) {
// Generate new session ID
$sessionId = Str::uuid()->toString();
-
+
// Store in cache with 5 minute TTL (will be refreshed on each request)
Cache::put($sessionKey, $sessionId, now()->addMinutes(5));
-
+
// New session created (no logging for performance)
} else {
// Refresh TTL
Cache::put($sessionKey, $sessionId, now()->addMinutes(5));
}
-
+
return $sessionId;
}
-
+
/**
* Update total viewer count for a stream across all edges
*/
@@ -232,21 +236,20 @@ private function updateStreamViewerCount($streamSlug)
{
// Get all edge servers
$edges = Server::getActiveEdges();
-
+
$totalViewers = 0;
foreach ($edges as $edge) {
$count = Cache::get("stream_viewers:{$streamSlug}:{$edge->id}", 0);
$totalViewers += $count;
}
-
+
// Store total count
Cache::put("stream_total_viewers:{$streamSlug}", $totalViewers, now()->addMinutes(2));
-
+
Log::info('Stream viewer count updated', [
'stream' => $streamSlug,
'total_viewers' => $totalViewers,
'edge_count' => $edges->count(),
]);
}
-
-}
\ No newline at end of file
+}
diff --git a/app/Http/Controllers/Api/RecordingApiController.php b/app/Http/Controllers/Api/RecordingApiController.php
index 49f41f7..5c6a6f1 100644
--- a/app/Http/Controllers/Api/RecordingApiController.php
+++ b/app/Http/Controllers/Api/RecordingApiController.php
@@ -11,16 +11,22 @@
class RecordingApiController extends Controller
{
/**
- * Get shows that need to be recorded.
- * Returns shows where recordable=true, actual_start and actual_end are set, and no recording exists.
+ * Shows still awaiting a published recording.
+ *
+ * Legacy: this was the work queue for an external processing server, back when
+ * producing a recording meant extracting and re-encoding MP4s. Cutting is now
+ * internal and instant (see ArchivePlaylistService), so nothing consumes this to do
+ * work any more. Kept because the endpoint is an external API contract, and narrowed
+ * to the honest question it can still answer: which announced shows have not been
+ * published yet.
*/
public function shows()
{
$shows = Show::with('source')
- ->where('recordable', true)
+ ->where('announce_recording', true)
->whereNotNull('actual_start')
->whereNotNull('actual_end')
- ->whereDoesntHave('recording')
+ ->whereDoesntHave('recordings', fn ($q) => $q->where('is_published', true))
->get()
->map(function ($show) {
return [
@@ -31,13 +37,14 @@ public function shows()
'end' => $show->actual_end->toIso8601String(),
'title' => $show->title,
'description' => $show->description,
+ 'description_html' => $show->description_html,
];
});
return response()->json([
'success' => true,
'data' => $shows,
- 'count' => $shows->count()
+ 'count' => $shows->count(),
]);
}
@@ -58,7 +65,7 @@ public function create(Request $request)
]);
// If show_id is provided, get the show details
- if (!empty($validated['show_id'])) {
+ if (! empty($validated['show_id'])) {
$show = Show::find($validated['show_id']);
// Use show details if not provided
@@ -84,7 +91,7 @@ public function create(Request $request)
$count = 1;
while (Recording::where('slug', $slug)->exists()) {
- $slug = $baseSlug . '-' . $count;
+ $slug = $baseSlug.'-'.$count;
$count++;
}
@@ -92,7 +99,7 @@ public function create(Request $request)
}
// Default to published
- if (!isset($validated['is_published'])) {
+ if (! isset($validated['is_published'])) {
$validated['is_published'] = true;
}
@@ -107,7 +114,7 @@ public function create(Request $request)
return response()->json([
'success' => true,
'data' => $recording,
- 'message' => 'Recording created successfully'
+ 'message' => 'Recording created successfully',
], 201);
}
@@ -122,7 +129,7 @@ public function getBySlug($slug)
return response()->json([
'success' => true,
- 'data' => $recording
+ 'data' => $recording,
]);
}
}
diff --git a/app/Http/Controllers/Api/ServerProvisionController.php b/app/Http/Controllers/Api/ServerProvisionController.php
index 8d81e88..5be25b0 100644
--- a/app/Http/Controllers/Api/ServerProvisionController.php
+++ b/app/Http/Controllers/Api/ServerProvisionController.php
@@ -26,7 +26,7 @@ public function config(Request $request, string $type)
// Find server by shared secret
$server = Server::where('shared_secret', $sharedSecret)->first();
- if (!$server) {
+ if (! $server) {
return response('Unauthorized', 401);
}
@@ -37,15 +37,15 @@ public function config(Request $request, string $type)
case 'nginx-origin':
$content = $this->provisioningService->generateNginxOriginConfig($server);
break;
-
+
case 'nginx-edge':
$content = $this->provisioningService->generateNginxEdgeConfig($server);
break;
-
+
case 'caddy-origin':
$content = $this->provisioningService->generateCaddyOriginConfig($server);
break;
-
+
case 'caddy-edge':
$content = $this->provisioningService->generateCaddyEdgeConfig($server);
break;
@@ -59,7 +59,18 @@ public function config(Request $request, string $type)
$content = $this->provisioningService->generateDockerCompose($server);
$contentType = 'application/x-yaml';
break;
-
+
+ // Edge nginx needs the njs module to verify playback tokens locally,
+ // so it is built on the host from this Dockerfile rather than pulled.
+ case 'dockerfile-edge':
+ $content = $this->provisioningService->generateConfig($server, 'edge-dockerfile');
+ break;
+
+ case 'hls-auth-js':
+ $content = $this->provisioningService->generateConfig($server, 'hls-auth-js');
+ $contentType = 'application/javascript';
+ break;
+
default:
return response('Not found', 404);
}
@@ -77,7 +88,7 @@ public function script(Request $request, string $script)
// Find server by shared secret
$server = Server::where('shared_secret', $sharedSecret)->first();
- if (!$server) {
+ if (! $server) {
return response('Unauthorized', 401);
}
@@ -115,19 +126,19 @@ public function register(Request $request)
]);
$server = Server::find($data['server_id']);
- if (!$server || $server->shared_secret !== $sharedSecret) {
+ if (! $server || $server->shared_secret !== $sharedSecret) {
return response()->json(['error' => 'Unauthorized'], 401);
}
// Check if this server can become origin (if it's an origin type)
- if ($server->type === \App\Enum\ServerTypeEnum::ORIGIN &&
+ if ($server->type === \App\Enum\ServerTypeEnum::ORIGIN &&
$data['status'] === \App\Enum\ServerStatusEnum::ACTIVE->value &&
- !$server->canBecomeOrigin()) {
+ ! $server->canBecomeOrigin()) {
Log::warning('Cannot activate origin server - another origin is already active', [
'server_id' => $server->id,
'hostname' => $data['hostname'],
]);
-
+
return response()->json([
'error' => 'Another origin server is already active',
'status' => 'conflict',
@@ -184,7 +195,7 @@ public function heartbeat(Request $request, Server $server)
// Optional: Include health data in request
$health = $request->input('health', []);
- if (!empty($health)) {
+ if (! empty($health)) {
$server->update([
'metadata' => array_merge($server->metadata ?? [], [
'health' => $health,
diff --git a/app/Http/Controllers/Api/SrsCallbackController.php b/app/Http/Controllers/Api/SrsCallbackController.php
index a2cfd93..865480a 100644
--- a/app/Http/Controllers/Api/SrsCallbackController.php
+++ b/app/Http/Controllers/Api/SrsCallbackController.php
@@ -30,7 +30,7 @@ public function auth(Request $request)
$tcUrl = $request->input('tcUrl');
$param = $request->input('param'); // Query string from RTMP URL (e.g., "?secret=xyz")
$clientIp = $request->input('ip'); // The actual client IP from SRS
-
+
// Allow internal transcoding to publish to "live" app
// Block external clients from publishing directly to "live" app
if ($app === 'live') {
@@ -42,108 +42,108 @@ public function auth(Request $request)
'stream' => $stream,
'ip' => $clientIp,
]);
-
+
// Allow internal transcoding without authentication
return response()->json(['code' => 0]);
}
-
+
// External client trying to publish to live app - reject
Log::warning('External publishing to live app rejected - use ingress app', [
'app' => $app,
'stream' => $stream,
'ip' => $clientIp,
]);
-
+
return response()->json(['code' => 403, 'msg' => 'Publishing to live app not allowed - use ingress app'], 403);
}
-
+
// Remove the leading '?' if present and parse parameters
$param = ltrim($param, '?');
parse_str($param, $params);
-
+
// Check for 'secret' parameter (which is the stream_key)
$streamKey = $params['secret'] ?? null;
-
+
// Also check for shared_secret for server-to-server auth
$sharedSecret = $params['shared_secret'] ?? null;
-
+
// Get the server making the request (for edge->origin auth)
$serverIp = $request->ip();
-
+
// First check if this is a server-to-server forward (edge to origin)
if ($sharedSecret) {
$server = Server::where('shared_secret', $sharedSecret)
->where('status', \App\Enum\ServerStatusEnum::ACTIVE)
->first();
-
+
if ($server) {
Log::info('Server-to-server auth successful', [
'server_id' => $server->id,
'hostname' => $server->hostname,
]);
-
+
// For server-to-server, still update the source status based on stream name
$source = Source::where('slug', $stream)->first();
if ($source) {
$previousStatus = $source->status->value;
$source->status = SourceStatusEnum::ONLINE;
$source->save();
-
+
// Broadcast status change event
if ($previousStatus !== SourceStatusEnum::ONLINE->value) {
broadcast(new SourceStatusChangedEvent($source, $previousStatus));
}
}
-
+
return response()->json([
'code' => 0,
'server' => [
'id' => (string) $server->id,
- 'signature' => md5($server->id . ':' . $server->shared_secret),
- ]
+ 'signature' => md5($server->id.':'.$server->shared_secret),
+ ],
]);
}
-
+
Log::warning('Server-to-server auth failed - invalid shared_secret', [
'ip' => $serverIp,
]);
-
+
return response()->json(['code' => 403], 403);
}
-
+
// Regular source authentication
- if (!$streamKey) {
+ if (! $streamKey) {
Log::warning('No stream key provided in publish request', [
'app' => $app,
'stream' => $stream,
'param' => $param,
]);
-
+
return response()->json(['code' => 403], 403);
}
-
+
// Find source by slug (stream name)
$source = Source::where('slug', $stream)->first();
-
- if (!$source) {
+
+ if (! $source) {
Log::warning('No source found for stream', [
'stream' => $stream,
]);
-
+
return response()->json(['code' => 403], 403);
}
-
+
// Verify the stream_key matches (encrypted field will auto-decrypt on access)
if ($source->stream_key !== $streamKey) {
Log::warning('Invalid stream key for source', [
'stream' => $stream,
'source_id' => $source->id,
- 'provided_key' => substr($streamKey, 0, 8) . '...',
+ 'provided_key' => substr($streamKey, 0, 8).'...',
]);
-
+
return response()->json(['code' => 403], 403);
}
-
+
Log::info('Stream auth successful', [
'source_id' => $source->id,
'source_name' => $source->name,
@@ -151,12 +151,12 @@ public function auth(Request $request)
'stream' => $stream,
'previous_status' => $source->status->value,
]);
-
+
// Update source status to online
$previousStatus = $source->status->value;
$source->status = SourceStatusEnum::ONLINE;
$source->save();
-
+
// Log recovery if coming from error state
if ($previousStatus === SourceStatusEnum::ERROR->value) {
Log::info('Source recovered from error state', [
@@ -164,22 +164,22 @@ public function auth(Request $request)
'source_name' => $source->name,
]);
}
-
+
// Broadcast status change event
if ($previousStatus !== SourceStatusEnum::ONLINE->value) {
broadcast(new SourceStatusChangedEvent($source, $previousStatus));
}
-
+
// Return success with source info
return response()->json([
'code' => 0,
'client' => [
'id' => (string) $source->id,
- 'signature' => md5($source->id . ':' . $streamKey),
- ]
+ 'signature' => md5($source->id.':'.$streamKey),
+ ],
]);
}
-
+
/**
* Handle SRS on_play webhook
* Called when a client wants to play a stream
@@ -190,13 +190,13 @@ public function play(Request $request)
Log::info('SRS play webhook called', [
'data' => $request->all(),
]);
-
+
// For now, allow all play requests
// You could add viewer authentication here if needed
-
+
return response()->json(['code' => 0]);
}
-
+
/**
* Handle SRS on_stop webhook
* Called when a client stops playing or publishing
@@ -207,12 +207,12 @@ public function stop(Request $request)
Log::info('SRS stop webhook called', [
'data' => $request->all(),
]);
-
+
// Clean up any session data if needed
-
+
return response()->json(['code' => 0]);
}
-
+
/**
* Handle SRS on_unpublish webhook
* Called when a stream stops publishing
@@ -222,23 +222,23 @@ public function unpublish(Request $request)
Log::info('SRS unpublish webhook called', [
'data' => $request->all(),
]);
-
+
$stream = $request->input('stream');
$app = $request->input('app');
-
+
// Find the source
$source = Source::where('slug', $stream)->first();
if ($source) {
$previousStatus = $source->status->value;
-
+
// Check if there's still a live show for this source
- $hasLiveShow = \App\Models\Show::where(function($query) use ($source) {
- $query->where('source_id', $source->id)
- ->orWhere('source_id', $source->slug);
- })
+ $hasLiveShow = \App\Models\Show::where(function ($query) use ($source) {
+ $query->where('source_id', $source->id)
+ ->orWhere('source_id', $source->slug);
+ })
->where('status', 'live')
->exists();
-
+
// If there's a live show, this is an unexpected disconnect (error)
// If no live show, this is an expected shutdown (offline)
if ($hasLiveShow) {
@@ -256,9 +256,9 @@ public function unpublish(Request $request)
'previous_status' => $previousStatus,
]);
}
-
+
$source->save();
-
+
// Broadcast status change event
if ($previousStatus !== $source->status->value) {
broadcast(new SourceStatusChangedEvent($source, $previousStatus));
@@ -268,16 +268,16 @@ public function unpublish(Request $request)
'stream' => $stream,
]);
}
-
+
Log::info('Stream unpublish processed', [
'app' => $app,
'stream' => $stream,
'final_status' => $source ? $source->status->value : 'unknown',
]);
-
+
return response()->json(['code' => 0]);
}
-
+
/**
* Handle SRS on_error webhook
* Called when there's a stream error or connection interruption
@@ -287,28 +287,28 @@ public function error(Request $request)
Log::info('SRS error webhook called', [
'data' => $request->all(),
]);
-
+
$stream = $request->input('stream');
$error = $request->input('error');
$description = $request->input('description');
-
+
// Update source status to error
$source = Source::where('slug', $stream)->first();
if ($source) {
$previousStatus = $source->status->value;
-
+
// Only set to error if currently online (to avoid overriding offline status)
if ($source->status === SourceStatusEnum::ONLINE) {
$source->status = SourceStatusEnum::ERROR;
$source->save();
-
+
Log::warning('Source status updated to error', [
'source_id' => $source->id,
'name' => $source->name,
'error' => $error,
'description' => $description,
]);
-
+
// Broadcast status change event
broadcast(new SourceStatusChangedEvent($source, $previousStatus));
}
@@ -318,10 +318,10 @@ public function error(Request $request)
'error' => $error,
]);
}
-
+
return response()->json(['code' => 0]);
}
-
+
/**
* Handle SRS on_hls webhook
* Called when a client requests an HLS stream
@@ -330,35 +330,35 @@ public function error(Request $request)
public function onHls(Request $request)
{
$data = $request->all();
-
+
Log::info('SRS on_hls callback', $data);
-
+
// Extract parameters from SRS callback
$stream = $data['stream'] ?? '';
$param = $data['param'] ?? '';
$clientId = $data['client_id'] ?? '';
$ip = $data['ip'] ?? '';
-
+
// Parse query parameters from param field
parse_str(ltrim($param, '?'), $params);
-
+
// Check for streamkey or session_id
$streamkey = $params['streamkey'] ?? $params['session_id'] ?? null;
-
+
// Check if it's the internal system session
if ($streamkey === config('stream.internal_session_id')) {
Log::info('SRS auth bypassed for internal session', [
'stream' => $stream,
'client_id' => $clientId,
]);
-
+
return response()->json(['code' => 0]);
}
-
+
// Validate streamkey if provided
if ($streamkey) {
$user = \App\Models\User::where('streamkey', $streamkey)->first();
-
+
if ($user) {
// Store user info with client_id for tracking
\Illuminate\Support\Facades\Cache::put("srs_client:{$clientId}", [
@@ -367,34 +367,36 @@ public function onHls(Request $request)
'stream' => $stream,
'ip' => $ip,
], now()->addHours(2));
-
+
Log::info('SRS HLS access granted', [
'user_id' => $user->id,
'user_name' => $user->name,
'stream' => $stream,
'client_id' => $clientId,
]);
-
+
return response()->json(['code' => 0]);
}
}
-
+
// Check if source exists and is online
$source = Source::where('slug', $stream)->first();
-
- if (!$source) {
+
+ if (! $source) {
Log::warning('Source not found for SRS HLS request', ['stream' => $stream]);
+
return response()->json(['code' => 404, 'msg' => 'Stream not found']);
}
-
+
if ($source->status !== SourceStatusEnum::ONLINE) {
Log::info('Source offline for SRS HLS request', [
'stream' => $stream,
'status' => $source->status->value,
]);
+
return response()->json(['code' => 404, 'msg' => 'Stream offline']);
}
-
+
// If no auth but stream is online, allow access (public stream)
// You might want to make this configurable per source
Log::info('SRS HLS public access granted', [
@@ -402,7 +404,7 @@ public function onHls(Request $request)
'client_id' => $clientId,
'ip' => $ip,
]);
-
+
return response()->json(['code' => 0]);
}
-}
\ No newline at end of file
+}
diff --git a/app/Http/Controllers/Api/SrsDvrController.php b/app/Http/Controllers/Api/SrsDvrController.php
index 4d40fe0..58c8b3f 100644
--- a/app/Http/Controllers/Api/SrsDvrController.php
+++ b/app/Http/Controllers/Api/SrsDvrController.php
@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api;
+use App\Http\Controllers\Controller;
use App\Models\DvrRecording;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
-use App\Http\Controllers\Controller;
class SrsDvrController extends Controller
{
@@ -18,10 +18,10 @@ public function handleDvrCallback(Request $request)
try {
// Log the DVR event
Log::info('DVR callback received', $request->all());
-
+
// Extract DVR information
$data = $request->all();
-
+
// Parse the file path to extract metadata
$filePath = $data['file'] ?? '';
$app = $data['app'] ?? '';
@@ -30,7 +30,7 @@ public function handleDvrCallback(Request $request)
$clientId = $data['client_id'] ?? null;
$ip = $data['ip'] ?? '';
$action = $data['action'] ?? '';
-
+
// Store DVR recording information in database (optional)
if ($filePath) {
$this->storeDvrRecording([
@@ -44,27 +44,27 @@ public function handleDvrCallback(Request $request)
'created_at' => now(),
]);
}
-
+
// Return success response
return response()->json([
'code' => 0,
- 'msg' => 'ok'
+ 'msg' => 'ok',
]);
-
+
} catch (\Exception $e) {
- Log::error('DVR callback error: ' . $e->getMessage(), [
+ Log::error('DVR callback error: '.$e->getMessage(), [
'request' => $request->all(),
- 'exception' => $e
+ 'exception' => $e,
]);
-
+
// SRS expects code 0 for success, non-zero for error
return response()->json([
'code' => 1,
- 'msg' => 'error: ' . $e->getMessage()
+ 'msg' => 'error: '.$e->getMessage(),
]);
}
}
-
+
/**
* Store DVR recording information
*/
@@ -74,18 +74,18 @@ private function storeDvrRecording(array $data)
// You can create a DvrRecording model to track recordings
// For now, just log it
Log::info('DVR recording created', $data);
-
+
// Optional: Store in database
// DvrRecording::create($data);
-
+
// Optional: Dispatch job for post-processing
// ProcessDvrRecording::dispatch($data);
-
+
} catch (\Exception $e) {
- Log::error('Failed to store DVR recording: ' . $e->getMessage());
+ Log::error('Failed to store DVR recording: '.$e->getMessage());
}
}
-
+
/**
* Handle S3 upload webhook from DVR uploader service
*/
@@ -93,9 +93,9 @@ public function handleUploadWebhook(Request $request)
{
try {
Log::info('DVR S3 upload webhook received', $request->all());
-
+
$data = $request->all();
-
+
// Extract information
$s3Bucket = $data['s3_bucket'] ?? '';
$s3Key = $data['s3_key'] ?? '';
@@ -103,23 +103,23 @@ public function handleUploadWebhook(Request $request)
$app = $data['app'] ?? '';
$stream = $data['stream'] ?? '';
$date = $data['date'] ?? '';
-
+
// Optional: Update recording status in database
// Optional: Send notifications
// Optional: Trigger VOD processing
-
+
return response()->json([
'status' => 'success',
- 'message' => 'Upload webhook processed'
+ 'message' => 'Upload webhook processed',
]);
-
+
} catch (\Exception $e) {
- Log::error('DVR upload webhook error: ' . $e->getMessage());
-
+ Log::error('DVR upload webhook error: '.$e->getMessage());
+
return response()->json([
'status' => 'error',
- 'message' => $e->getMessage()
+ 'message' => $e->getMessage(),
], 500);
}
}
-}
\ No newline at end of file
+}
diff --git a/app/Http/Controllers/Auth/FrontChannelLogoutController.php b/app/Http/Controllers/Auth/FrontChannelLogoutController.php
index 075a219..caa5373 100644
--- a/app/Http/Controllers/Auth/FrontChannelLogoutController.php
+++ b/app/Http/Controllers/Auth/FrontChannelLogoutController.php
@@ -3,12 +3,13 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
+use App\Services\BrandingService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
class FrontChannelLogoutController extends Controller
{
- public function __invoke(Request $request)
+ public function __invoke(Request $request, BrandingService $branding)
{
\Auth::logout();
@@ -16,6 +17,16 @@ public function __invoke(Request $request)
$request->session()->invalidate();
$request->session()->regenerateToken();
- return redirect("https://identity.eurofurence.org/oauth2/sessions/logout?id_token_hint={$idToken}");
+ // The provider endpoint is per installation. Without one configured the
+ // local session is already gone, so there is nowhere else to send them.
+ $logoutUrl = trim((string) $branding->get('identity_logout_url'));
+
+ if ($logoutUrl === '') {
+ return redirect('/');
+ }
+
+ $separator = str_contains($logoutUrl, '?') ? '&' : '?';
+
+ return redirect($logoutUrl.$separator.'id_token_hint='.urlencode((string) $idToken));
}
}
diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php
index 3633ffd..c94c465 100644
--- a/app/Http/Controllers/Auth/LoginController.php
+++ b/app/Http/Controllers/Auth/LoginController.php
@@ -3,12 +3,65 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
+use App\Models\Show;
+use Illuminate\Contracts\Database\Query\Builder;
+use Illuminate\Support\Collection;
use Inertia\Inertia;
class LoginController extends Controller
{
+ /**
+ * How far ahead the schedule rail on the login screen looks.
+ */
+ private const SCHEDULE_WINDOW_HOURS = 20;
+
+ /**
+ * How many rows it shows, however much fits in that window.
+ */
+ private const SCHEDULE_LENGTH = 6;
+
public function __invoke()
{
- return Inertia::render('Auth/Login');
+ return Inertia::render('Auth/Login', [
+ 'schedule' => $this->schedule(),
+ ]);
+ }
+
+ /**
+ * Whatever is on now, plus anything starting in the next 20 hours, in clock
+ * order. Ended and cancelled shows never appear.
+ */
+ private function schedule(): Collection
+ {
+ return Show::query()
+ // accessibleBy(null) keeps role-restricted shows out of the rail,
+ // which is rendered before anyone has signed in.
+ ->accessibleBy(null)
+ ->with('source')
+ ->where(function (Builder $query) {
+ // Live shows stay listed no matter when they were scheduled to
+ // start, since they are on right now.
+ $query->where('status', 'live')
+ ->orWhere(fn (Builder $upcoming) => $upcoming
+ ->where('status', 'scheduled')
+ ->whereBetween('scheduled_start', [
+ now(),
+ now()->addHours(self::SCHEDULE_WINDOW_HOURS),
+ ]));
+ })
+ // Live shows sort by when they actually started, so a stream that has
+ // been running since yesterday stays above tonight's line-up.
+ ->orderByRaw('COALESCE(actual_start, scheduled_start)')
+ ->limit(self::SCHEDULE_LENGTH)
+ ->get()
+ ->map(fn (Show $show) => [
+ 'id' => $show->id,
+ 'title' => $show->title,
+ 'source' => $show->source?->name,
+ 'time' => ($show->actual_start ?? $show->scheduled_start)?->format('H:i'),
+ // Drives both the highlighted row and the LIVE marker.
+ 'current' => $show->status === 'live',
+ ])
+ ->values();
}
}
diff --git a/app/Http/Controllers/Auth/OidcClientController.php b/app/Http/Controllers/Auth/OidcClientController.php
index 482ae5c..e504777 100644
--- a/app/Http/Controllers/Auth/OidcClientController.php
+++ b/app/Http/Controllers/Auth/OidcClientController.php
@@ -3,7 +3,9 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
+use App\Models\Role;
use App\Models\User;
+use App\Services\BrandingService;
use App\Services\Hydra\Client;
use App\Services\OpenIDService;
use Illuminate\Http\RedirectResponse;
@@ -35,8 +37,15 @@ public function callback(Request $request)
/**
* Only Identity Client - Redirects to error page if scope is invalid
*/
+ Log::info('OIDC DEBUG callback entered', ['query' => $request->query(), 'session_id' => Session::getId()]);
if (isset($data['error'])) {
- return Redirect::route('auth.login');
+ Log::warning('OIDC callback returned an error from the identity provider', $data);
+
+ // The provider rejected the authorize request (a replayed flow, a rotated CSRF
+ // cookie on its side, an expired flow). Bouncing to auth.login would start yet
+ // another authorize round and loop, hiding the error, so stop at the sign-in
+ // screen and say so.
+ return $this->failed('Sign-in was refused. Start again from this page, in a single tab.');
}
/**
@@ -45,9 +54,13 @@ public function callback(Request $request)
* otherwise null === null and it would pass the check falsely.
*/
if ($request->get('state') !== Session::get('login.oauth2state', false)) {
+ Log::warning('OIDC callback state did not match the session', [
+ 'got' => $request->get('state'),
+ 'expected' => Session::get('login.oauth2state', false),
+ ]);
Session::remove('login.oauth2state');
- return Redirect::route('auth.login');
+ return $this->failed('The sign-in request expired or was started in another session.');
}
Session::flush();
/**
@@ -59,9 +72,15 @@ public function callback(Request $request)
]);
$userinfoRequest = Http::identity()->withToken($accessToken->getToken())->get('/api/v1/userinfo');
if ($userinfoRequest->successful() === false) {
- return Redirect::route('auth.login');
+ Log::warning('OIDC userinfo request failed', ['status' => $userinfoRequest->status(), 'body' => $userinfoRequest->body()]);
+
+ // Named via BrandingService so a saved override wins over the config default.
+ $identity = app(BrandingService::class)->all()['identity_name'] ?? 'the identity provider';
+
+ return $this->failed("Your account details could not be read from {$identity}.");
}
$userinfo = $userinfoRequest->json();
+ Log::info('OIDC DEBUG userinfo ok', ['keys' => array_keys($userinfo ?? [])]);
if (! isset($userinfo['sub'])) {
throw new UnexpectedValueException('Could not request user id from freshly fetched token.');
@@ -75,16 +94,24 @@ public function callback(Request $request)
]);
$user = $user->fresh();
- // Fetch attendee packages from EF registration API
+ // Fetch attendee packages from the registration API
$packages = $this->fetchAttendeePackages($userid);
// Sync roles from registration system (groups and packages)
$roleSlugs = $this->mapGroupsAndPackagesToRoles($userinfo['groups'] ?? [], $packages);
$user->syncRolesFromLogin($roleSlugs);
- Auth::loginUsingId($user->id);
+ // Remembered, so the sign-in survives the session cookie expiring or the session
+ // store being cleared. Attendees should not be bounced back to the identity
+ // provider mid-convention.
+ Auth::loginUsingId($user->id, remember: true);
Session::put('access_token', $accessToken);
- Session::put('avatar', $userinfo['avatar']);
+ Session::put('avatar', $userinfo['avatar'] ?? null);
+ Log::info('OIDC DEBUG logged in', [
+ 'user_id' => $user->id,
+ 'auth_check' => Auth::check(),
+ 'session_id' => Session::getId(),
+ ]);
// Middleware will handle server assignment and redirect if needed
return $this->redirectDestination($request);
@@ -92,6 +119,17 @@ public function callback(Request $request)
public function login(Request $request): RedirectResponse
{
+ if ($rejection = $this->redirectUriRejection()) {
+ Log::error('OIDC redirect URI will be rejected by the provider', [
+ 'redirect_uri' => route('auth.callback'),
+ 'reason' => $rejection,
+ ]);
+
+ if (! app()->isProduction()) {
+ return $this->failed($rejection);
+ }
+ }
+
$provider = $this->openIDService->setupOIDC($request, $this->clientIsAdmin($request));
$authorizationUrl = $provider->getAuthorizationUrl();
Session::put('login.oauth2state', $provider->getState());
@@ -104,13 +142,62 @@ public function clientIsAdmin(Request $request)
return false;
}
+ /**
+ * End a broken sign-in at the sign-in screen rather than at the flow initiator.
+ *
+ * `auth.login` immediately redirects to the provider's authorize endpoint, so
+ * redirecting a failure there restarts the flow: the operator sees a redirect loop
+ * instead of a message, and the underlying error stays invisible.
+ *
+ * The provider's own description is kept out of the response on purpose; it leaks
+ * internals ("the CSRF value from the token does not match ...") and means nothing to
+ * an attendee. It is in the log for whoever is debugging.
+ */
+ /**
+ * OAuth2 providers refuse a plain-http redirect URI unless the host is localhost or a
+ * `*.localhost` subdomain. Ory Hydra answers with
+ * `invalid_request: Redirect URL is using an insecure protocol ...` only *after* a full
+ * round trip through the authorize endpoint, which reads like a login failure rather
+ * than a misconfigured APP_URL. Catch it before leaving the app.
+ *
+ * @return string|null The reason, or null when the redirect URI is acceptable.
+ */
+ private function redirectUriRejection(): ?string
+ {
+ $uri = route('auth.callback');
+ $parts = parse_url($uri);
+
+ if (($parts['scheme'] ?? 'http') === 'https') {
+ return null;
+ }
+
+ $host = $parts['host'] ?? '';
+
+ if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
+ return null;
+ }
+
+ return "Sign-in is misconfigured: the callback URL {$uri} uses http, which the identity "
+ .'provider only accepts for localhost hosts. Set APP_URL to an https URL, or to a '
+ .'*.localhost host, and make sure that callback URL is registered for this client.';
+ }
+
+ private function failed(?string $reason = null): RedirectResponse
+ {
+ Session::remove('login.oauth2state');
+
+ return Redirect::route('login')->withErrors([
+ 'oidc' => $reason ?? 'Sign-in could not be completed. Please try again.',
+ ]);
+ }
+
private function redirectDestination(Request $request)
{
return Redirect::route('shows.grid');
}
/**
- * Fetch attendee packages from EF registration API
+ * Fetch attendee packages from the registration API
* This is optional - if the registration system is offline, we silently continue without packages
*/
private function fetchAttendeePackages(string $userId): array
@@ -177,50 +264,60 @@ private function fetchAttendeePackages(string $userId): array
}
/**
- * Map registration system groups and packages to role slugs
+ * Collect the external identifiers this sign-in grants.
+ *
+ * Nothing is translated to a role here. A role claims an identifier by
+ * putting it in its `external_id`, so which role a group ID or a package
+ * maps to is a question for the roles table, editable in /manage, rather
+ * than something wired into this class.
+ *
+ * @param array