CluPilotCloud/routes/admin.php

116 lines
5.9 KiB
PHP

<?php
use App\Http\Controllers\ImpersonationController;
use App\Livewire\Admin;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
/*
| The operator console.
|
| Its own file, and registered BEFORE routes/web.php, because once the console
| has a hostname to itself it lives at the ROOT of that host — where `/` and
| `/settings` also exist in the customer portal, and the first matching route
| wins.
|
| Where it is mounted is AdminArea's decision, not this file's: `/` on the
| console hostname where the console has one to itself, `/admin` on any host
| otherwise. Route NAMES are `admin.*` in both cases, so every route('admin.…')
| in the views is unaffected by which mode an installation is in.
|
| RestrictAdminHost is listed here as well as prepended to the `web` group:
| Livewire records it on the component snapshot and re-applies it to
| /livewire/update, so a console action cannot be driven through another host.
*/
Route::get('/', Admin\Overview::class)->name('overview');
Route::get('/customers', Admin\Customers::class)->name('customers');
Route::get('/instances', Admin\Instances::class)->name('instances');
Route::get('/hosts', Admin\Hosts::class)->name('hosts');
Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create');
Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show');
Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters');
Route::get('/plans', Admin\Plans::class)->name('plans');
Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions');
// POST so the identity change is CSRF-protected (a GET could be forced cross-site).
Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate');
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
Route::get('/vpn', Admin\Vpn::class)->name('vpn');
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/mail', Admin\Mail::class)->name('mail');
Route::get('/secrets', Admin\Secrets::class)->name('secrets');
Route::get('/settings', Admin\Settings::class)->name('settings');
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
// exempting the rest of admin.settings — see that middleware for why.
Route::get('/two-factor-setup', Admin\TwoFactorSetup::class)->name('two-factor-setup');
// POST so Laravel's CSRF middleware protects it, like every other state change.
Route::post('/logout', function () {
Auth::guard('operator')->logout();
// NOT session()->invalidate(): where the console and the portal share a
// host (shared/fallback mode — this VM's own mode), both guards keep
// their login key in the SAME session. invalidate() is flush() +
// migrate(true) — it discards every attribute in the session, not just
// the operator's, so it would silently sign a customer out of the portal
// too if the same browser happened to carry both. regenerate() issues a
// new session id (no fixation risk) without touching the other
// attributes, so the 'web' guard's own login key — already removed for
// 'operator' by the logout() call above — survives untouched.
session()->regenerate();
session()->regenerateToken();
return redirect()->route('admin.login');
})->name('logout');
/*
* A deployment restarts the very application that is watching it.
*
* The settings card polls itself with wire:poll, which is fine until the run
* reaches the restart: the requests fail, and what comes back afterwards is a
* new build being asked questions by the old page's JavaScript. So the card
* never learned that the update had finished and an operator had to reload to
* find out — reported exactly that way.
*
* This is the smallest thing that can answer "is it done, and is it still the
* same build" across that gap: no Livewire, no component state, no assets. A
* failed request here is expected (it IS the restart) and the poller keeps
* trying rather than giving up.
*/
Route::get('/update/state', function (UpdateChannel $channel) {
$state = $channel->state();
// Only once a run has actually started, not while merely queued: before
// that, lastLog() would return the PREVIOUS run's leftovers, which would
// sit under a "starts in 0:47" countdown looking like the current run
// already has a history.
$started = $state['started_at'] !== null;
return response()->json([
'running' => $state['running'],
'commit' => $state['commit'],
'behind' => $state['behind'],
'last_finished_at' => $state['last_finished_at']?->toIso8601String(),
// Already localized ("Schritt: …"), the same sentence the settings
// card itself renders — so the maintenance overlay (layouts/admin)
// only ever displays a ready-made string and never assembles German
// text in JavaScript.
'step' => $state['phase'] !== null
? __('admin_settings.update_step', ['step' => $state['phase']])
: null,
// The raw instant, not a sentence: the overlay ticks a live "Startet
// in 0:47" from this on the client, which needs an unambiguous point
// in time to count down to, not translated text.
'next_check_at' => $state['next_check_at']?->toIso8601String(),
// Ready to print — the same sentence update_since renders on the
// settings card — so the overlay never assembles it either.
'running_since' => $started
? __('admin_settings.update_since', ['when' => $state['started_at']->diffForHumans()])
: null,
// The tail of the run log: a run that is genuinely stuck is then
// visibly stuck AT a step, instead of a spinner nobody can read
// anything from.
'log' => $started ? $channel->lastLog() : null,
])->header('Cache-Control', 'no-store');
})->name('update.state');