From 139b29733e24f4c6fb3ff59ab80f00a9a1c35869 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 22:32:37 +0930 Subject: [PATCH 1/4] fix: serve Pulse and Horizon dashboards under admin/ to avoid username route collisions - Pulse: set PULSE_PATH default to admin/pulse; Pulse::ignoreRoutes() in AppServiceProvider and register the dashboard explicitly in routes/web-admin.php so it is matched before the {username} profile catch-all. - Horizon: set horizon.path to admin/horizon (all Horizon routes follow); add an explicit base-path GET mirroring Horizon's own HomeController@index for clarity. - Remove now-unneeded 'horizon' reservation from RestrictedNames. - Add PULSE_ENABLED-gated Pulse links to admin sidenav and topnav. - Replace HorizonRedirectTest with HorizonDashboardTest (base path now renders the dashboard directly instead of redirecting). --- app/Providers/AppServiceProvider.php | 1 + app/Util/Lexer/RestrictedNames.php | 3 - config/horizon.php | 2 +- config/pulse.php | 2 +- .../views/admin/partial/sidenav.blade.php | 11 +++- .../views/admin/partial/topnav.blade.php | 5 +- routes/web-admin.php | 28 +++++++++ routes/web.php | 4 -- tests/Feature/Admin/HorizonDashboardTest.php | 53 +++++++++++++++++ tests/Feature/Admin/HorizonRedirectTest.php | 57 ------------------- 10 files changed, 98 insertions(+), 68 deletions(-) create mode 100644 tests/Feature/Admin/HorizonDashboardTest.php delete mode 100644 tests/Feature/Admin/HorizonRedirectTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 861a07541..d8a563a3b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -230,6 +230,7 @@ class AppServiceProvider extends ServiceProvider public function register() { Passport::ignoreRoutes(); + Pulse::ignoreRoutes(); $this->app->bind(UserOidcService::class, function () { return UserOidcService::build(); diff --git a/app/Util/Lexer/RestrictedNames.php b/app/Util/Lexer/RestrictedNames.php index 21295554e..c2a32aac2 100644 --- a/app/Util/Lexer/RestrictedNames.php +++ b/app/Util/Lexer/RestrictedNames.php @@ -99,9 +99,6 @@ class RestrictedNames 'mix-manifest.json', 'robots.txt', - // Laravel Horizon - 'horizon', - // Reserved routes 'a', 'app', diff --git a/config/horizon.php b/config/horizon.php index ea330b81a..9cee4194e 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -26,7 +26,7 @@ return [ | */ - 'path' => 'horizon', + 'path' => env('HORIZON_PATH', 'admin/horizon'), /* |-------------------------------------------------------------------------- diff --git a/config/pulse.php b/config/pulse.php index 7341ca7f0..2d993143f 100644 --- a/config/pulse.php +++ b/config/pulse.php @@ -30,7 +30,7 @@ return [ | */ - 'path' => env('PULSE_PATH', 'pulse'), + 'path' => env('PULSE_PATH', 'admin/pulse'), /* |-------------------------------------------------------------------------- diff --git a/resources/views/admin/partial/sidenav.blade.php b/resources/views/admin/partial/sidenav.blade.php index ce9ef0471..70e20a58e 100644 --- a/resources/views/admin/partial/sidenav.blade.php +++ b/resources/views/admin/partial/sidenav.blade.php @@ -146,12 +146,21 @@ + @if(config('pulse.enabled')) + + @endif + diff --git a/routes/web-admin.php b/routes/web-admin.php index cb508f696..b6e75d7fb 100644 --- a/routes/web-admin.php +++ b/routes/web-admin.php @@ -5,6 +5,34 @@ use App\Http\Controllers\AdminController; use App\Http\Controllers\AdminCuratedRegisterController; use App\Http\Controllers\AdminShadowFilterController; use App\Http\Controllers\PageController; +use Illuminate\Contracts\View\Factory; +use Laravel\Horizon\Http\Controllers\HomeController as HorizonHomeController; +use Laravel\Pulse\Pulse; + +// Laravel Pulse + Horizon dashboards, kept under `admin/*` so their routes can +// never collide with the `{username}` profile catch-all in routes/web.php +// (a username cannot contain a slash). This file loads before routes/web.php and +// before Horizon's provider routes (see bootstrap/app.php), so these definitions +// win on route-match order. +// +// Pulse: registered explicitly (with Pulse::ignoreRoutes() set in AppServiceProvider +// so the package does not self-register at its default path). +// +// Horizon: the explicit base-path GET below is deliberately identical to Horizon's +// own optional `{view?}` catch-all (same HomeController@index action, same 'horizon' +// middleware group) which would otherwise serve this path. It is not required for +// correctness; it exists only to make admin/horizon visible in Pixelfed's own route +// files. All other Horizon subpaths (admin/horizon/dashboard, /api/*, assets) are +// still served by Horizon itself. +Route::domain(config('pixelfed.domain.app'))->middleware(['localization'])->group(function () { + Route::get(config('pulse.path', 'admin/pulse'), function (Pulse $pulse, Factory $view) { + return $view->make('pulse::dashboard'); + })->middleware('pulse')->name('pulse'); + + Route::get(config('horizon.path'), [HorizonHomeController::class, 'index']) + ->middleware('horizon') + ->name('horizon.base'); +}); Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->middleware(['localization'])->group(function () { Route::redirect('/', '/dashboard'); diff --git a/routes/web.php b/routes/web.php index b1970f0b8..d4188fe96 100644 --- a/routes/web.php +++ b/routes/web.php @@ -500,10 +500,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['localization'])->grou Route::get('auth/invite/a/{code}', [AdminInviteController::class, 'index']); Route::post('api/v1.1/auth/invite/admin/re', [AdminInviteController::class, 'apiRegister'])->middleware('throttle:5,1440'); - // Laravel 13's Horizon no longer redirects the base path to its dashboard, - // so /horizon 404s by default. Redirect admins from /horizon to /horizon/dashboard. - Route::redirect('horizon', '/horizon/dashboard')->middleware('admin'); - Route::redirect('groups/', '/groups/home'); Route::redirect('groups/home', '/groups/feed'); diff --git a/tests/Feature/Admin/HorizonDashboardTest.php b/tests/Feature/Admin/HorizonDashboardTest.php new file mode 100644 index 000000000..d33dc79ca --- /dev/null +++ b/tests/Feature/Admin/HorizonDashboardTest.php @@ -0,0 +1,53 @@ +admin()->create(); + $admin->refresh(); + + // The base path is handled by Horizon (not a redirect) and renders the SPA. + $this->actingAs($admin) + ->get('/'.$path) + ->assertOk() + ->assertSee('Horizon', false); +}); + +it('serves the Horizon dashboard at the /dashboard subpath for an admin', function () { + $path = config('horizon.path'); + + $admin = User::factory()->admin()->create(); + $admin->refresh(); + + $this->actingAs($admin) + ->get('/'.$path.'/dashboard') + ->assertOk(); +}); + +it('does not allow a non-admin to access the Horizon dashboard', function () { + $user = User::factory()->create(['is_admin' => false]); + $user->refresh(); + + // Horizon's authorization gate (viewHorizon) forbids non-admins. + $this->actingAs($user) + ->get('/'.config('horizon.path')) + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/HorizonRedirectTest.php b/tests/Feature/Admin/HorizonRedirectTest.php deleted file mode 100644 index 416fcb108..000000000 --- a/tests/Feature/Admin/HorizonRedirectTest.php +++ /dev/null @@ -1,57 +0,0 @@ -admin()->create(); - $admin->refresh(); - - $response = $this->actingAs($admin)->get('/horizon'); - - $response->assertRedirect('/horizon/dashboard'); - - // The Location header must be absolute so a trailing-slash request cannot - // resolve it into a doubled /horizon/horizon/dashboard path. - expect($response->headers->get('Location'))->toEndWith('/horizon/dashboard'); - expect(parse_url($response->headers->get('Location'), PHP_URL_PATH)) - ->toBe('/horizon/dashboard'); -}); - -it('redirects with an absolute Location for a trailing-slash /horizon/ request', function () { - $admin = User::factory()->admin()->create(); - $admin->refresh(); - - $response = $this->actingAs($admin)->get('/horizon/'); - - // Regardless of the trailing slash, the redirect path is the absolute - // dashboard path, never a relative reference that would double up. - expect(parse_url($response->headers->get('Location'), PHP_URL_PATH)) - ->toBe('/horizon/dashboard'); -}); - -it('does not allow a non-admin to use the /horizon redirect', function () { - $user = User::factory()->create(['is_admin' => false]); - $user->refresh(); - - // The admin middleware bounces non-admins to the app root, not the dashboard. - $this->actingAs($user) - ->get('/horizon') - ->assertRedirect(config('app.url')); -}); From 5654ab6c270c4687a32abc87646bcf7c91209db7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 22:41:00 +0930 Subject: [PATCH 2/4] fix: viewPulse gate denies admins due to strict === on boolean is_admin is_admin is cast to bool on the User model, so $user->is_admin === 1 is always false and the gate 403s every user, admins included. Use a boolean check, consistent with the viewHorizon gate. --- app/Providers/AppServiceProvider.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d8a563a3b..a98826628 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -112,7 +112,9 @@ class AppServiceProvider extends ServiceProvider Event::listen(Failed::class, LogFailedLogin::class); Gate::define('viewPulse', function (User $user) { - return $user->is_admin === 1; + // is_admin is cast to bool on the User model, so a strict `=== 1` + // never matches. Use a boolean check, consistent with viewHorizon. + return (bool) $user->is_admin === true; }); if (config('pulse.enabled', false)) { From b06e810375a75ef252b89e6bf865352520a14172 Mon Sep 17 00:00:00 2001 From: Shlee Date: Tue, 22 Sep 2026 22:44:18 +0930 Subject: [PATCH 3/4] Update web-admin.php --- routes/web-admin.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/routes/web-admin.php b/routes/web-admin.php index b6e75d7fb..d97d552a1 100644 --- a/routes/web-admin.php +++ b/routes/web-admin.php @@ -11,19 +11,6 @@ use Laravel\Pulse\Pulse; // Laravel Pulse + Horizon dashboards, kept under `admin/*` so their routes can // never collide with the `{username}` profile catch-all in routes/web.php -// (a username cannot contain a slash). This file loads before routes/web.php and -// before Horizon's provider routes (see bootstrap/app.php), so these definitions -// win on route-match order. -// -// Pulse: registered explicitly (with Pulse::ignoreRoutes() set in AppServiceProvider -// so the package does not self-register at its default path). -// -// Horizon: the explicit base-path GET below is deliberately identical to Horizon's -// own optional `{view?}` catch-all (same HomeController@index action, same 'horizon' -// middleware group) which would otherwise serve this path. It is not required for -// correctness; it exists only to make admin/horizon visible in Pixelfed's own route -// files. All other Horizon subpaths (admin/horizon/dashboard, /api/*, assets) are -// still served by Horizon itself. Route::domain(config('pixelfed.domain.app'))->middleware(['localization'])->group(function () { Route::get(config('pulse.path', 'admin/pulse'), function (Pulse $pulse, Factory $view) { return $view->make('pulse::dashboard'); From 662b7bdee8d73d87972baca23734022117aacf5c Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 22:47:39 +0930 Subject: [PATCH 4/4] fix(larastan): resolve octane-compatibility error in CaptchaServiceProvider The singleton closure injected the boot-time $app and constructed new CaptchaManager($app), which larastan's checkOctaneCompatibility flags as holding a stale container across requests. Resolve the current container via Container::getInstance() instead. --- app/Providers/CaptchaServiceProvider.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/Providers/CaptchaServiceProvider.php b/app/Providers/CaptchaServiceProvider.php index 56d98b423..29cc0aadd 100644 --- a/app/Providers/CaptchaServiceProvider.php +++ b/app/Providers/CaptchaServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Services\Captcha\CaptchaManager; +use Illuminate\Container\Container; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Validator; use Illuminate\Support\ServiceProvider; @@ -11,7 +12,12 @@ class CaptchaServiceProvider extends ServiceProvider { public function register(): void { - $this->app->singleton('captcha.manager', fn ($app): CaptchaManager => new CaptchaManager($app)); + // Resolve the current container via Container::getInstance() rather than + // capturing the $app injected at boot time, so the manager never holds a + // stale container instance across requests under Octane. + $this->app->singleton('captcha.manager', function (): CaptchaManager { + return new CaptchaManager(Container::getInstance()); + }); $this->app->alias('captcha.manager', CaptchaManager::class); }