diff --git a/src/Http/Controllers/PhoneHomeController.php b/src/Http/Controllers/PhoneHomeController.php index 450dd112dd7..f46213fdaed 100644 --- a/src/Http/Controllers/PhoneHomeController.php +++ b/src/Http/Controllers/PhoneHomeController.php @@ -4,17 +4,17 @@ use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Config; -use Statamic\Licensing\Outpost; +use Statamic\Licensing\Radio; class PhoneHomeController { - public function __invoke(Outpost $outpost, $token) + public function __invoke(Radio $radio, $token) { if (! password_verify(Config::getLicenseKey(), base64_decode($token))) { throw new NotFoundHttpException; } - $outpost->radio(); + $radio->forcePing(); return response()->json(['success' => true]); } diff --git a/src/Http/Middleware/CP/ContactOutpost.php b/src/Http/Middleware/CP/ContactOutpost.php deleted file mode 100644 index 33c7d1f4f71..00000000000 --- a/src/Http/Middleware/CP/ContactOutpost.php +++ /dev/null @@ -1,23 +0,0 @@ -outpost = $outpost; - } - - public function handle($request, Closure $next) - { - $this->outpost->radio(); - - return $next($request); - } -} diff --git a/src/Http/Middleware/PingOutpost.php b/src/Http/Middleware/PingOutpost.php new file mode 100644 index 00000000000..198b4b9f1e1 --- /dev/null +++ b/src/Http/Middleware/PingOutpost.php @@ -0,0 +1,31 @@ +radio->shouldPingDuringRequest($request)) { + $this->radio->ping(); + } + + return $next($request); + } + + public function terminate(Request $request, Response $response): void + { + if ($this->radio->shouldPingAfterResponse($request)) { + $this->radio->ping(); + } + } +} diff --git a/src/Licensing/Outpost.php b/src/Licensing/Outpost.php index 4f536cf1377..01e8baec3fc 100644 --- a/src/Licensing/Outpost.php +++ b/src/Licensing/Outpost.php @@ -159,7 +159,7 @@ private function hasCachedResponse() private function payloadHasChanged($previous, $current) { - $exclude = ['ip', 'php_version']; + $exclude = ['host', 'ip', 'port', 'php_version']; return Arr::except($previous, $exclude) !== Arr::except($current, $exclude); } diff --git a/src/Licensing/Radio.php b/src/Licensing/Radio.php new file mode 100644 index 00000000000..4acc4258a65 --- /dev/null +++ b/src/Licensing/Radio.php @@ -0,0 +1,186 @@ +markAsPinged()) { + return; + } + + $this->contactOutpost(); + } + + public function forcePing(): void + { + $this->cache()->put(self::PING_CACHE_KEY, now()->timestamp, self::PING_INTERVAL); + + $this->contactOutpost(); + } + + private function contactOutpost(): void + { + try { + $this->outpost->radio(); + } catch (Throwable $e) { + Log::debug('Error contacting Outpost: '.$e->getMessage()); + } + } + + /** + * An atomic put-if-absent, so concurrent requests at marker expiry + * cannot herd into Outpost together. Returns false when the + * marker already exists and the ping should be skipped. + */ + private function markAsPinged(): bool + { + return $this->cache()->add(self::PING_CACHE_KEY, now()->timestamp, self::PING_INTERVAL); + } + + private function cache(): Repository + { + try { + return Cache::store('outpost'); + } catch (InvalidArgumentException $e) { + return Cache::store(); + } + } + + public function shouldPingRequest(Request $request): bool + { + if ($request->isLivePreview()) { + return false; + } + + return ! $this->isGlideRequest($request); + } + + public function shouldPingDuringRequest(Request $request): bool + { + if (app()->runningUnitTests()) { + return false; + } + + return $this->isCpRequest($request) && $this->shouldPingRequest($request); + } + + public function shouldPingAfterResponse(Request $request): bool + { + if (app()->runningUnitTests()) { + return false; + } + + return ! $this->isCpRequest($request) && $this->shouldPingRequest($request); + } + + public function shouldPingCommand(?string $command): bool + { + if (app()->runningUnitTests() || $this->runningInCi()) { + return false; + } + + return ! $this->isCommandIgnored($command); + } + + public function isCommandIgnored(?string $command): bool + { + if (! $command) { + return true; + } + + if (in_array($command, $this->ignoredCommands(), true)) { + return true; + } + + foreach ($this->ignoredCommandPrefixes() as $prefix) { + if ($command === $prefix || str_starts_with($command, $prefix.':')) { + return true; + } + } + + return false; + } + + private function isCpRequest(Request $request): bool + { + if (! config('statamic.cp.enabled')) { + return false; + } + + $cp = config('statamic.cp.route'); + $path = $request->path(); + + return $path === $cp + || Str::startsWith($path, Str::finish($cp, '/')); + } + + private function isGlideRequest(Request $request): bool + { + $route = trim((string) Glide::route(), '/'); + + if ($route === '') { + return false; + } + + return $request->is($route, $route.'/*', '*/'.$route, '*/'.$route.'/*'); + } + + private function runningInCi(): bool + { + return filter_var($_SERVER['CI'] ?? $_ENV['CI'] ?? getenv('CI'), FILTER_VALIDATE_BOOLEAN); + } + + /** + * @return list + */ + private function ignoredCommandPrefixes(): array + { + return [ + 'horizon', + 'nightwatch', + 'octane', + 'pail', + 'pulse', + 'queue', + 'reverb', + 'schedule', + ]; + } + + /** + * @return list + */ + private function ignoredCommands(): array + { + return [ + 'completion', + 'docs', + 'dump-server', + 'help', + 'inspire', + 'list', + 'pest', + 'serve', + 'test', + 'tinker', + ]; + } +} diff --git a/src/Listeners/PingOutpostOnCommandFinished.php b/src/Listeners/PingOutpostOnCommandFinished.php new file mode 100644 index 00000000000..6026fa6f5f8 --- /dev/null +++ b/src/Listeners/PingOutpostOnCommandFinished.php @@ -0,0 +1,22 @@ +radio->shouldPingCommand($event->command)) { + return; + } + + $this->radio->ping(); + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index b30e1d23b6a..507d81d19cc 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -20,7 +20,9 @@ use Statamic\Facades\Token; use Statamic\Facades\User; use Statamic\Fields\FieldsetRecursionStack; +use Statamic\Http\Middleware\PingOutpost; use Statamic\Jobs\HandleEntrySchedule; +use Statamic\Licensing\Radio; use Statamic\Notifications\ElevatedSessionVerificationCode; use Statamic\Sites\Sites; use Statamic\Stache\Query\RevisionQueryBuilder; @@ -51,7 +53,8 @@ public function boot() ->pushMiddleware(\Statamic\Http\Middleware\PoweredByHeader::class) ->pushMiddleware(\Statamic\Http\Middleware\CheckComposerJsonScripts::class) ->pushMiddleware(\Statamic\Http\Middleware\CheckMultisite::class) - ->pushMiddleware(\Statamic\Http\Middleware\StopImpersonating::class); + ->pushMiddleware(\Statamic\Http\Middleware\StopImpersonating::class) + ->pushMiddleware(PingOutpost::class); $this->loadViewsFrom("{$this->root}/resources/views", 'statamic'); @@ -140,6 +143,12 @@ public function boot() if (config('statamic.system.handle_scheduled_entries')) { $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); } + + $this->app->make(Schedule::class) + ->call(fn () => app(Radio::class)->ping()) + ->hourly() + ->name('statamic-outpost') + ->withoutOverlapping(); } public function register() diff --git a/src/Providers/CpServiceProvider.php b/src/Providers/CpServiceProvider.php index b640613b146..037099a55ec 100644 --- a/src/Providers/CpServiceProvider.php +++ b/src/Providers/CpServiceProvider.php @@ -81,7 +81,6 @@ protected function registerMiddlewareGroups() \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, \Statamic\Http\Middleware\CP\HandleInertiaRequests::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, - \Statamic\Http\Middleware\CP\ContactOutpost::class, \Statamic\Http\Middleware\CP\AuthGuard::class, \Statamic\Http\Middleware\CP\AddToasts::class, \Statamic\Http\Middleware\CP\TrimStrings::class, diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index 0c8fa5b6a91..6117a48812f 100755 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -29,6 +29,9 @@ class EventServiceProvider extends ServiceProvider \Illuminate\Foundation\Http\Events\RequestHandled::class => [ \Statamic\Listeners\ClearState::class, ], + \Illuminate\Console\Events\CommandFinished::class => [ + \Statamic\Listeners\PingOutpostOnCommandFinished::class, + ], ]; protected $subscribe = [ diff --git a/tests/Http/Middleware/PingOutpostTest.php b/tests/Http/Middleware/PingOutpostTest.php new file mode 100644 index 00000000000..c95f494b67c --- /dev/null +++ b/tests/Http/Middleware/PingOutpostTest.php @@ -0,0 +1,66 @@ +assertTrue( + $this->app->make(\Illuminate\Contracts\Http\Kernel::class)->hasMiddleware(PingOutpost::class) + ); + } + + #[Test] + public function it_pings_during_cp_requests() + { + $request = Request::create('/cp/dashboard'); + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingDuringRequest')->once()->with($request)->andReturnTrue(); + $radio->shouldReceive('ping')->once(); + $radio->shouldReceive('shouldPingAfterResponse')->once()->with($request)->andReturnFalse(); + + $middleware = new PingOutpost($radio); + $response = $middleware->handle($request, fn () => new Response); + + $middleware->terminate($request, $response); + } + + #[Test] + public function it_pings_after_front_end_responses() + { + $request = Request::create('/about'); + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingDuringRequest')->once()->with($request)->andReturnFalse(); + $radio->shouldReceive('shouldPingAfterResponse')->once()->with($request)->andReturnTrue(); + $radio->shouldReceive('ping')->once(); + + $middleware = new PingOutpost($radio); + $response = $middleware->handle($request, fn () => new Response); + + $middleware->terminate($request, $response); + } + + #[Test] + public function it_does_not_ping_when_radio_declines() + { + $request = Request::create('/img/foo.jpg'); + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingDuringRequest')->once()->with($request)->andReturnFalse(); + $radio->shouldReceive('shouldPingAfterResponse')->once()->with($request)->andReturnFalse(); + $radio->shouldReceive('ping')->never(); + + $middleware = new PingOutpost($radio); + $response = $middleware->handle($request, fn () => new Response); + + $middleware->terminate($request, $response); + } +} diff --git a/tests/Licensing/OutpostScheduleTest.php b/tests/Licensing/OutpostScheduleTest.php new file mode 100644 index 00000000000..7719699737a --- /dev/null +++ b/tests/Licensing/OutpostScheduleTest.php @@ -0,0 +1,20 @@ +app->make(Schedule::class)->events()) + ->first(fn ($event) => $event->description === 'statamic-outpost'); + + $this->assertNotNull($event); + $this->assertEquals('0 * * * *', $event->expression); + } +} diff --git a/tests/Licensing/OutpostTest.php b/tests/Licensing/OutpostTest.php index fd1f2d43763..17a02670a96 100644 --- a/tests/Licensing/OutpostTest.php +++ b/tests/Licensing/OutpostTest.php @@ -91,6 +91,25 @@ public function the_cached_response_is_used() $this->assertSame($first, $second); } + #[Test] + public function the_cached_response_is_used_when_only_the_environment_has_changed() + { + $outpost = $this->outpostWithJsonResponse(['newer' => 'response']); + + $payload = $outpost->payload(); + $payload['host'] = 'some-other-host.com'; + $payload['ip'] = '9.9.9.9'; + $payload['port'] = null; + $payload['php_version'] = '1.2.3'; + + $this->setCachedResponse($testCachedResponse = [ + 'cached' => 'response', + 'payload' => $payload, + ]); + + $this->assertEquals($testCachedResponse, $outpost->response()); + } + #[Test] public function license_key_file_is_used_when_it_exists() { diff --git a/tests/Licensing/RadioTest.php b/tests/Licensing/RadioTest.php new file mode 100644 index 00000000000..aeb2b162e4e --- /dev/null +++ b/tests/Licensing/RadioTest.php @@ -0,0 +1,167 @@ +flush(); + } + + #[Test] + public function it_contacts_the_outpost() + { + $outpost = $this->mock(Outpost::class); + $outpost->shouldReceive('radio')->once(); + + (new Radio($outpost))->ping(); + } + + #[Test] + public function it_throttles_pings() + { + $outpost = $this->mock(Outpost::class); + $outpost->shouldReceive('radio')->twice(); + + $radio = new Radio($outpost); + + $radio->ping(); + $radio->ping(); // Throttled. + + Carbon::setTestNow(now()->addSeconds(Radio::PING_INTERVAL + 1)); + + $radio->ping(); + } + + #[Test] + public function force_pings_bypass_the_throttle_but_still_refresh_it() + { + $outpost = $this->mock(Outpost::class); + $outpost->shouldReceive('radio')->twice(); + + $radio = new Radio($outpost); + + $radio->ping(); + $radio->forcePing(); // Bypasses the throttle. + $radio->ping(); // Still throttled. + } + + #[Test] + public function it_swallows_outpost_exceptions() + { + $outpost = $this->mock(Outpost::class); + $outpost->shouldReceive('radio')->once()->andThrow(new RuntimeException('nope')); + + $radio = new Radio($outpost); + + $radio->ping(); + $radio->ping(); // Still throttled after a failure. + } + + #[Test] + public function it_does_not_ping_during_cp_requests_during_tests() + { + $request = Request::create('/cp/dashboard'); + + $this->assertFalse($this->radio()->shouldPingDuringRequest($request)); + $this->assertFalse($this->radio()->shouldPingAfterResponse($request)); + $this->assertTrue($this->radio()->shouldPingRequest($request)); + } + + #[Test] + public function it_does_not_ping_after_front_end_responses_during_tests() + { + $request = Request::create('/about'); + + $this->assertFalse($this->radio()->shouldPingDuringRequest($request)); + $this->assertFalse($this->radio()->shouldPingAfterResponse($request)); + $this->assertTrue($this->radio()->shouldPingRequest($request)); + } + + #[Test] + public function it_skips_glide_requests() + { + $request = Request::create('/img/foo.jpg'); + + $this->assertFalse($this->radio()->shouldPingRequest($request)); + } + + #[Test] + public function it_skips_site_prefixed_glide_requests() + { + $request = Request::create('/fr/img/foo.jpg'); + + $this->assertFalse($this->radio()->shouldPingRequest($request)); + } + + #[Test] + public function it_does_not_ping_commands_during_tests() + { + $this->assertFalse($this->radio()->shouldPingCommand('statamic:stache:clear')); + $this->assertFalse($this->radio()->shouldPingCommand('migrate')); + } + + #[Test] + #[DataProvider('ignoredCommandsProvider')] + public function it_ignores_noisy_commands(?string $command) + { + $this->assertTrue($this->radio()->isCommandIgnored($command)); + } + + public static function ignoredCommandsProvider(): array + { + return [ + 'null' => [null], + 'empty' => [''], + 'list' => ['list'], + 'help' => ['help'], + 'tinker' => ['tinker'], + 'serve' => ['serve'], + 'schedule:run' => ['schedule:run'], + 'schedule:work' => ['schedule:work'], + 'queue:work' => ['queue:work'], + 'queue:listen' => ['queue:listen'], + 'horizon' => ['horizon'], + 'horizon:work' => ['horizon:work'], + 'octane:start' => ['octane:start'], + 'reverb:start' => ['reverb:start'], + 'test' => ['test'], + 'pest' => ['pest'], + ]; + } + + #[Test] + #[DataProvider('allowedCommandsProvider')] + public function it_does_not_ignore_normal_commands(string $command) + { + $this->assertFalse($this->radio()->isCommandIgnored($command)); + } + + public static function allowedCommandsProvider(): array + { + return [ + 'statamic:stache:clear' => ['statamic:stache:clear'], + 'statamic:install' => ['statamic:install'], + 'migrate' => ['migrate'], + 'statamic:make:user' => ['statamic:make:user'], + 'about' => ['about'], + ]; + } + + private function radio(): Radio + { + return new Radio($this->mock(Outpost::class)); + } +} diff --git a/tests/Listeners/PingOutpostOnCommandFinishedTest.php b/tests/Listeners/PingOutpostOnCommandFinishedTest.php new file mode 100644 index 00000000000..7bcebe867f2 --- /dev/null +++ b/tests/Listeners/PingOutpostOnCommandFinishedTest.php @@ -0,0 +1,39 @@ +mock(Radio::class); + $radio->shouldReceive('shouldPingCommand')->once()->with('statamic:stache:clear')->andReturnTrue(); + $radio->shouldReceive('ping')->once(); + + (new PingOutpostOnCommandFinished($radio))->handle($this->event('statamic:stache:clear')); + } + + #[Test] + public function it_does_not_ping_when_the_command_should_be_skipped() + { + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingCommand')->once()->with('schedule:run')->andReturnFalse(); + $radio->shouldReceive('ping')->never(); + + (new PingOutpostOnCommandFinished($radio))->handle($this->event('schedule:run')); + } + + private function event(?string $command): CommandFinished + { + return new CommandFinished($command, new ArrayInput([]), new NullOutput, 0); + } +}