diff --git a/README.md b/README.md index 76737b7..1248268 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,42 @@ -# kyledoesdev - Laravel Essentials +## kyledoesdev/essentials -Essential utilities for my Laravel projects. +Essential utilities for my Laravel applications — the helpers, macros and generators I'd otherwise copy between projects. -## Installation +### Installation ```bash composer require kyledoesdev/essentials ``` -## Features +### Features -### Global Timezone Helper +**Timezone detection** — `timezone()` resolves the timezone for the current request from the authenticated user, an IP geo lookup, then `config('app.timezone')`. Results are cached per IP address. -```php -timezone() // Returns the timezone of the current request()->ip() -``` +**Carbon macro** — `Carbon::parse($date)->inUserTimezone()` shifts an instance into that timezone. -### Carbon Macro +**Action generator** — `php artisan make:action CreateUserAction` scaffolds action classes into `app/Actions`. -```php -Carbon::parse($created_at)->inUserTimezone() // Converts carbon instance to user's timezone -``` +**Developer middleware** — `IsDeveloper` aborts with a 403 unless the authenticated user has `is_dev`. + +**Model stats** — `HasStatsAfterEvents` records created and deleted counts through spatie/laravel-stats. + +**String helpers** — `toSafeFileName()` strips characters that break downloads. -### Action Class Generator +### Configuration ```bash -php artisan make:action CreateUserAction +php artisan vendor:publish --tag=essentials-config ``` -Generates action classes in `app/Actions` directory. +### Requirements -## Requirements - -- Laravel 11+ +- Laravel 12+ - PHP 8.4 / 8.5 -## License -MIT license. \ No newline at end of file +### License + +essentials is open-sourced software licensed under the MIT license. + +### Credits + +Created by [kyledoesdev](https://github.com/kyledoesdev) diff --git a/config/essentials.php b/config/essentials.php index 9781468..f4944ee 100644 --- a/config/essentials.php +++ b/config/essentials.php @@ -3,5 +3,7 @@ return [ 'timezone' => [ 'local_envs' => explode(',', (string) env('LOCAL_ENVS', 'local,development,dev')), + 'ttl' => (int) env('TIMEZONE_CACHE_TTL', 86400), + 'retry_after' => (int) env('TIMEZONE_RETRY_AFTER', 300), ], ]; diff --git a/src/Concerns/HasStatsAfterEvents.php b/src/Concerns/HasStatsAfterEvents.php index 22e3dad..240e8c4 100644 --- a/src/Concerns/HasStatsAfterEvents.php +++ b/src/Concerns/HasStatsAfterEvents.php @@ -9,7 +9,7 @@ trait HasStatsAfterEvents { - public static function bootStatsAfterEvents() + public static function bootHasStatsAfterEvents() { static::created(function (Model $model) { dispatch(fn() => StatsWriter::for(StatsEvent::class, ['name' => $model::statsClass()])->increase()); diff --git a/src/Middleware/IsDeveloper.php b/src/Middleware/IsDeveloper.php index 70f0ac1..5937211 100644 --- a/src/Middleware/IsDeveloper.php +++ b/src/Middleware/IsDeveloper.php @@ -10,9 +10,7 @@ class IsDeveloper { public function handle(Request $request, Closure $next): Response { - if (auth()->check() && ! auth()->user()->is_dev) { - abort(403); - } + abort_unless(auth()->user()?->is_dev, 403); return $next($request); } diff --git a/src/Providers/MacroServiceProvider.php b/src/Providers/MacroServiceProvider.php index 5c1cd8e..ec9e53c 100644 --- a/src/Providers/MacroServiceProvider.php +++ b/src/Providers/MacroServiceProvider.php @@ -3,22 +3,19 @@ namespace Kyledoesdev\Essentials\Providers; use Carbon\Carbon; +use Carbon\CarbonImmutable; use Illuminate\Support\ServiceProvider; class MacroServiceProvider extends ServiceProvider { public function boot(): void { - Carbon::macro('inUserTimezone', function () { - $timezone = auth()->user()?->timezone ?? session()->get('____tz'); - - if (is_null($timezone)) { - $timezone = timezone(); - - session()->put(['____tz' => $timezone]); - } + $this->bootCarbonMacros(); + } - return $this->tz($timezone); - }); + private function bootCarbonMacros(): void + { + Carbon::macro('inUserTimezone', fn () => $this->tz(timezone())); + CarbonImmutable::macro('inUserTimezone', fn () => $this->tz(timezone())); } } diff --git a/src/Services/TimezoneService.php b/src/Services/TimezoneService.php index 5e8bf3a..2ffda42 100644 --- a/src/Services/TimezoneService.php +++ b/src/Services/TimezoneService.php @@ -2,48 +2,85 @@ namespace Kyledoesdev\Essentials\Services; +use DateTimeZone; +use Exception; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; final class TimezoneService { + private const CACHE_PREFIX = 'essentials:tz:'; + public function detect(): string + { + return $this->normalize(Auth::user()?->timezone) + ?? $this->fromIpAddress() + ?? $this->fallback(); + } + + private function fromIpAddress(): ?string { $ip = request()->ip(); - if (!$ip || $this->inDevEnv($ip)) { - return $this->default(); + if (! $this->canLookup($ip)) { + return null; } - return $this->fetchTimezone($ip); + $key = self::CACHE_PREFIX.$ip; + $cached = Cache::get($key); + + if ($cached) { + return $cached; + } + + $timezone = $this->normalize($this->lookup($ip)); + + Cache::put($key, $timezone ?? $this->fallback(), $this->ttlFor($timezone)); + + return $timezone; } - private function fetchTimezone(string $ip): string + private function lookup(string $ip): ?string { - $tz = rescue(fn () => Http::timeout(3) - ->get("http://ip-api.com/json/{$ip}") + $timezone = rescue(fn () => Http::timeout(3) + ->get("http://ip-api.com/json/{$ip}", ['fields' => 'status,timezone']) ->json('timezone') ); - return $tz ? $this->sanitize($tz) : $this->default(); + return is_string($timezone) ? $timezone : null; } - private function sanitize(string $timezone): string + private function canLookup(?string $ip): bool { - return match ($timezone) { + return filled($ip) + && ! in_array($ip, ['127.0.0.1', '::1'], true) + && ! in_array(app()->environment(), (array) config('essentials.timezone.local_envs', []), true); + } + + private function ttlFor(?string $timezone): int + { + return is_null($timezone) + ? (int) config('essentials.timezone.retry_after', 300) + : (int) config('essentials.timezone.ttl', 86400); + } + + private function normalize(?string $timezone): ?string + { + $timezone = match ($timezone) { 'Europe/Kiev' => 'Europe/Kyiv', default => $timezone, }; - } - private function inDevEnv(string $ip): bool - { - return - in_array(app()->environment(), config('essentials.timezone.local_envs', [])) || - in_array($ip, ['127.0.0.1', '::1']); + try { + return (new DateTimeZone((string) $timezone))->getName(); + } catch (Exception) { + return null; + } } - private function default(): string + private function fallback(): string { return config('app.timezone', 'UTC'); } -} \ No newline at end of file +} diff --git a/tests/Feature/CarbonMacroTest.php b/tests/Feature/CarbonMacroTest.php index 49d5f0c..0ed72b3 100644 --- a/tests/Feature/CarbonMacroTest.php +++ b/tests/Feature/CarbonMacroTest.php @@ -1,15 +1,17 @@ 'America/New_York']; +beforeEach(fn () => Http::preventStrayRequests()); +describe('Carbon Macro Helpers', function () { + test('inUserTimezone uses user timezone', function () { Auth::shouldReceive('user') ->once() - ->andReturn($user); + ->andReturn((object) ['timezone' => 'America/New_York']); $date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone(); @@ -25,4 +27,39 @@ expect($date->tzName)->toBe(config('app.timezone')); }); + + test('inUserTimezone uses the ip detected timezone when no user is authenticated', function () { + config(['essentials.timezone.local_envs' => []]); + request()->server->set('REMOTE_ADDR', '8.8.8.8'); + + Auth::shouldReceive('user')->once()->andReturn(null); + + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/Chicago'])]); + + $date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone(); + + expect($date->tzName)->toBe('America/Chicago'); + }); + + test('inUserTimezone is available on CarbonImmutable', function () { + Auth::shouldReceive('user') + ->once() + ->andReturn((object) ['timezone' => 'America/New_York']); + + $date = CarbonImmutable::parse('2025-01-01 12:00:00')->inUserTimezone(); + + expect($date)->toBeInstanceOf(CarbonImmutable::class) + ->and($date->tzName)->toBe('America/New_York'); + }); + + test('inUserTimezone shifts the underlying instant rather than relabelling it', function () { + Auth::shouldReceive('user') + ->once() + ->andReturn((object) ['timezone' => 'America/New_York']); + + $date = Carbon::parse('2025-01-01 12:00:00', 'UTC')->inUserTimezone(); + + expect($date->format('Y-m-d H:i:s'))->toBe('2025-01-01 07:00:00') + ->and($date->utc()->format('H:i:s'))->toBe('12:00:00'); + }); }); diff --git a/tests/Feature/HasStatsAfterEventsTest.php b/tests/Feature/HasStatsAfterEventsTest.php new file mode 100644 index 0000000..ded8a37 --- /dev/null +++ b/tests/Feature/HasStatsAfterEventsTest.php @@ -0,0 +1,18 @@ +hasListeners('eloquent.created: '.get_class($model)))->toBeTrue() + ->and($dispatcher->hasListeners('eloquent.deleted: '.get_class($model)))->toBeTrue(); + }); +}); diff --git a/tests/Feature/TimezoneServiceTest.php b/tests/Feature/TimezoneServiceTest.php index b08e67a..3ca964e 100644 --- a/tests/Feature/TimezoneServiceTest.php +++ b/tests/Feature/TimezoneServiceTest.php @@ -1,121 +1,194 @@ Http::preventStrayRequests()); +beforeEach(function () { + Http::preventStrayRequests(); + + config(['essentials.timezone.local_envs' => []]); + + request()->server->set('REMOTE_ADDR', '8.8.8.8'); +}); describe('TimezoneService', function () { it('returns default timezone when ip is null', function () { request()->server->set('REMOTE_ADDR', null); - $service = new TimezoneService(); + expect((new TimezoneService)->detect())->toBe('UTC'); - expect($service->detect())->toBe(config('app.timezone', 'UTC')); + Http::assertNothingSent(); }); it('returns default timezone for localhost ipv4', function () { request()->server->set('REMOTE_ADDR', '127.0.0.1'); - $service = new TimezoneService(); + expect((new TimezoneService)->detect())->toBe('UTC'); - expect($service->detect())->toBe(config('app.timezone', 'UTC')); + Http::assertNothingSent(); }); it('returns default timezone for localhost ipv6', function () { request()->server->set('REMOTE_ADDR', '::1'); - $service = new TimezoneService(); + expect((new TimezoneService)->detect())->toBe('UTC'); - expect($service->detect())->toBe(config('app.timezone', 'UTC')); + Http::assertNothingSent(); }); it('returns default timezone when in configured local environment', function () { config(['essentials.timezone.local_envs' => ['testing']]); - request()->server->set('REMOTE_ADDR', '8.8.8.8'); - $service = new TimezoneService(); + expect((new TimezoneService)->detect())->toBe('UTC'); - expect($service->detect())->toBe(config('app.timezone', 'UTC')); + Http::assertNothingSent(); }); it('fetches timezone from ip-api for valid ip', function () { - config(['essentials.timezone.local_envs' => []]); - request()->server->set('REMOTE_ADDR', '8.8.8.8'); - - Http::fake([ - 'ip-api.com/*' => Http::response(['timezone' => 'America/New_York']), - ]); + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/New_York'])]); - $service = new TimezoneService(); + expect((new TimezoneService)->detect())->toBe('America/New_York'); - expect($service->detect())->toBe('America/New_York'); + Http::assertSentCount(1); }); it('sanitizes Europe/Kiev to Europe/Kyiv', function () { - config(['essentials.timezone.local_envs' => []]); - request()->server->set('REMOTE_ADDR', '8.8.8.8'); + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'Europe/Kiev'])]); - Http::fake([ - 'ip-api.com/*' => Http::response(['timezone' => 'Europe/Kiev']), - ]); + expect((new TimezoneService)->detect())->toBe('Europe/Kyiv'); + }); - $service = new TimezoneService(); + it('returns default timezone when api request fails', function () { + Http::fake(['ip-api.com/*' => Http::response(null, 500)]); + + expect((new TimezoneService)->detect())->toBe('UTC'); - expect($service->detect())->toBe('Europe/Kyiv'); + // The lookup must actually have been attempted, otherwise this + // would pass even with the http call removed entirely. + Http::assertSentCount(1); }); - it('returns default timezone when api request fails', function () { - config(['essentials.timezone.local_envs' => []]); - request()->server->set('REMOTE_ADDR', '8.8.8.8'); + it('returns default timezone when api returns null timezone', function () { + Http::fake(['ip-api.com/*' => Http::response(['status' => 'fail'])]); - Http::fake([ - 'ip-api.com/*' => Http::response(null, 500), - ]); + expect((new TimezoneService)->detect())->toBe('UTC'); + + Http::assertSentCount(1); + }); - $service = new TimezoneService(); + it('returns default timezone when api returns a non string timezone', function () { + Http::fake(['ip-api.com/*' => Http::response(['timezone' => ['America/New_York']])]); - expect($service->detect())->toBe(config('app.timezone', 'UTC')); + expect((new TimezoneService)->detect())->toBe('UTC'); }); - it('returns default timezone when api returns null timezone', function () { - config(['essentials.timezone.local_envs' => []]); - request()->server->set('REMOTE_ADDR', '8.8.8.8'); + it('returns default timezone when api returns an unknown identifier', function () { + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'Mars/Olympus_Mons'])]); + + expect((new TimezoneService)->detect())->toBe('UTC'); + }); +}); + +describe('TimezoneService user timezone', function () { + it('prefers the authenticated user timezone over an ip lookup', function () { + actingAsUserWithTimezone('Australia/Sydney'); + + expect((new TimezoneService)->detect())->toBe('Australia/Sydney'); + + Http::assertNothingSent(); + }); + + it('accepts a legacy timezone identifier stored on the user', function () { + actingAsUserWithTimezone('US/Eastern'); + + expect((new TimezoneService)->detect())->toBe('US/Eastern'); + + Http::assertNothingSent(); + }); + + it('falls through to the ip lookup when the user timezone is invalid', function () { + actingAsUserWithTimezone('Not/AZone'); - Http::fake([ - 'ip-api.com/*' => Http::response(['status' => 'fail']), + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/New_York'])]); + + expect((new TimezoneService)->detect())->toBe('America/New_York'); + }); + + it('falls through to the ip lookup when the user has no timezone', function () { + actingAsUserWithTimezone(null); + + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/New_York'])]); + + expect((new TimezoneService)->detect())->toBe('America/New_York'); + }); +}); + +describe('TimezoneService caching', function () { + it('caches a successful lookup against the ip address', function () { + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/New_York'])]); + + expect((new TimezoneService)->detect())->toBe('America/New_York'); + expect((new TimezoneService)->detect())->toBe('America/New_York'); + + Http::assertSentCount(1); + expect(Cache::get('essentials:tz:8.8.8.8'))->toBe('America/New_York'); + }); + + it('caches a failed lookup so the api is not hammered', function () { + Http::fake(['ip-api.com/*' => Http::response(null, 500)]); + + expect((new TimezoneService)->detect())->toBe('UTC'); + expect((new TimezoneService)->detect())->toBe('UTC'); + + Http::assertSentCount(1); + }); + + it('does not reuse a cached timezone for a different ip', function () { + Cache::put('essentials:tz:8.8.8.8', 'America/New_York', 60); + request()->server->set('REMOTE_ADDR', '1.1.1.1'); + + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'Europe/Paris'])]); + + expect((new TimezoneService)->detect())->toBe('Europe/Paris'); + }); + + it('honors the configured cache ttls', function () { + config([ + 'essentials.timezone.ttl' => 3600, + 'essentials.timezone.retry_after' => 60, ]); - $service = new TimezoneService(); + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/New_York'])]); - expect($service->detect())->toBe(config('app.timezone', 'UTC')); + Cache::shouldReceive('get')->once()->andReturn(null); + Cache::shouldReceive('put')->once()->with('essentials:tz:8.8.8.8', 'America/New_York', 3600); + + expect((new TimezoneService)->detect())->toBe('America/New_York'); }); }); describe('Timezone Helper Function', function () { it('returns timezone from service', function () { - config(['essentials.timezone.local_envs' => []]); - request()->server->set('REMOTE_ADDR', '8.8.8.8'); - - Http::fake([ - 'ip-api.com/*' => Http::response(['timezone' => 'America/Chicago']), - ]); + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/Chicago'])]); expect(timezone())->toBe('America/Chicago'); }); it('caches result via once', function () { - config(['essentials.timezone.local_envs' => []]); - request()->server->set('REMOTE_ADDR', '8.8.8.8'); + Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/Denver'])]); - Http::fake([ - 'ip-api.com/*' => Http::response(['timezone' => 'America/Denver']), - ]); + expect(timezone())->toBe(timezone()); - $first = timezone(); - $second = timezone(); - - expect($first)->toBe($second); Http::assertSentCount(1); }); -}); \ No newline at end of file +}); + +/** Stub the authenticated user without needing a real User model. */ +function actingAsUserWithTimezone(?string $timezone): void +{ + Auth::shouldReceive('user')->andReturn( + is_null($timezone) ? null : (object) ['timezone' => $timezone] + ); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 30ce5a5..f137e2d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,5 +25,6 @@ protected function getEnvironmentSetUp($app): void { config()->set('database.default', 'testing'); config()->set('app.timezone', 'UTC'); + config()->set('cache.default', 'array'); } }