diff --git a/.env.example b/.env.example index eb423aaa..a1c38110 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,8 @@ SESSION_SAME_SITE=strict LOG_SLACK_WEBHOOK_URL= +BRRR_WEBHOOK_URL= + FLARE_KEY= IGNITION_OPEN_AI_KEY= diff --git a/app/Console/Commands/ReprocessMediaImages.php b/app/Console/Commands/ReprocessMediaImages.php index c5e22d1b..b6c86c47 100644 --- a/app/Console/Commands/ReprocessMediaImages.php +++ b/app/Console/Commands/ReprocessMediaImages.php @@ -6,9 +6,9 @@ namespace App\Console\Commands; use App\Models\Media; use Illuminate\Console\Command; +use Illuminate\Image\ImageException; +use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Storage; -use Intervention\Image\Exceptions\DecoderException; -use Intervention\Image\ImageManager; class ReprocessMediaImages extends Command { @@ -16,7 +16,7 @@ class ReprocessMediaImages extends Command protected $description = 'Regenerate medium and small image variants using correct aspect-ratio scaling'; - public function handle(ImageManager $manager): void + public function handle(): void { $media = Media::where('type', 'image') ->whereNotNull('image_widths') @@ -44,10 +44,10 @@ class ReprocessMediaImages extends Command $this->info("Processing: {$path}"); + $image = Image::fromStorage($path, 'public'); try { - $file = Storage::disk('public')->get($path); - $image = $manager->read($file); - } catch (DecoderException) { + $image->width(); + } catch (ImageException) { $this->warn(' Could not decode image, skipping.'); continue; @@ -57,11 +57,8 @@ class ReprocessMediaImages extends Command $extension = array_pop($filenameParts); $basename = trim(implode('.', $filenameParts), '.'); - $medium = $image->scale(width: 1000); - Storage::disk('public')->put($basename.'-medium.'.$extension, (string) $medium->encode()); - - $small = $image->scale(width: 500); - Storage::disk('public')->put($basename.'-small.'.$extension, (string) $small->encode()); + Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes()); + Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes()); $this->info(' Done.'); } diff --git a/app/Http/Controllers/AboutPageController.php b/app/Http/Controllers/AboutPageController.php new file mode 100644 index 00000000..d174e550 --- /dev/null +++ b/app/Http/Controllers/AboutPageController.php @@ -0,0 +1,16 @@ + About::first()?->content, + ]); + } +} diff --git a/app/Http/Controllers/Admin/AboutController.php b/app/Http/Controllers/Admin/AboutController.php new file mode 100644 index 00000000..cc5be0ad --- /dev/null +++ b/app/Http/Controllers/Admin/AboutController.php @@ -0,0 +1,32 @@ + $about, + ]); + } + + public function update(Request $request): RedirectResponse + { + $about = About::firstOrNew(); + $about->content = $request->input('content'); + $about->save(); + + return redirect()->route('admin.about.show'); + } +} diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php index 17e4a8a7..211f9fa8 100644 --- a/app/Http/Controllers/Admin/ContactsController.php +++ b/app/Http/Controllers/Admin/ContactsController.php @@ -6,11 +6,11 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Contact; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\BadResponseException; use Illuminate\Filesystem\Filesystem; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Http; use Illuminate\View\View; class ContactsController extends Controller @@ -113,14 +113,13 @@ class ContactsController extends Controller $avatar = null; $contact = Contact::findOrFail($contactId); if ($contact->homepage !== null && mb_strlen($contact->homepage) !== 0) { - $client = resolve(Client::class); try { - $response = $client->get($contact->homepage); - } catch (BadResponseException $e) { + $response = Http::throw()->get($contact->homepage); + } catch (RequestException $e) { return redirect('/admin/contacts/'.$contactId.'/edit') ->with('error', 'Bad resposne from contact’s homepage'); } - $mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage); + $mf2 = \Mf2\parse($response->body(), $contact->homepage); foreach ($mf2['items'] as $microformat) { if (Arr::get($microformat, 'type.0') === 'h-card') { $avatarURL = Arr::get($microformat, 'properties.photo.0.value'); @@ -129,8 +128,8 @@ class ContactsController extends Controller } if ($avatarURL !== null) { try { - $avatar = $client->get($avatarURL); - } catch (BadResponseException $e) { + $avatar = Http::throw()->get($avatarURL); + } catch (RequestException $e) { return redirect('/admin/contacts/'.$contactId.'/edit') ->with('error', 'Unable to download avatar'); } @@ -141,7 +140,7 @@ class ContactsController extends Controller if ($filesystem->isDirectory($directory) === false) { $filesystem->makeDirectory($directory); } - $filesystem->put($directory.'/image', $avatar->getBody()); + $filesystem->put($directory.'/image', $avatar->body()); return view('admin.contacts.getavatarsuccess', [ 'homepage' => parse_url($contact->homepage, PHP_URL_HOST), diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php index 8012f9b8..d9c18f5b 100644 --- a/app/Http/Controllers/Admin/PasskeysController.php +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -254,7 +254,7 @@ class PasskeysController extends Controller ], 400); } - $passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id); + $passkey = Passkey::firstWhere('passkey_id', Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId)); if (! $passkey) { return response()->json([ 'success' => false, diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php new file mode 100644 index 00000000..99d283bb --- /dev/null +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -0,0 +1,32 @@ + $settings, + ]); + } + + public function update(Request $request): RedirectResponse + { + $settings = Setting::firstOrNew(); + $settings->winter_effect_enabled = $request->boolean('winter_effect_enabled'); + $settings->save(); + + return redirect()->route('admin.settings.show'); + } +} diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php new file mode 100644 index 00000000..02b9660c --- /dev/null +++ b/app/Http/Controllers/Admin/TokensController.php @@ -0,0 +1,65 @@ +get(); + + return view('admin.tokens.index', compact('tokens')); + } + + /** + * Show the form to manually generate a new Micropub token. + * + * This is for clients (e.g. iA Writer) that don't support the IndieAuth + * PKCE flow and instead expect to be given a token directly. + */ + public function create(): View + { + return view('admin.tokens.create'); + } + + /** + * Manually generate a new Micropub token. + */ + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'client_id' => 'required|string', + 'scope' => 'required|array|min:1', + ]); + + $token = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => $validated['client_id'], + 'scope' => implode(' ', $validated['scope']), + ]); + + return redirect('/admin/tokens')->with('new_token', $token); + } + + /** + * Revoke a Micropub token. + */ + public function revoke(MicropubToken $token): RedirectResponse + { + $token->revoke(); + + return redirect('/admin/tokens'); + } +} diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index a30c89dc..9aeb4abb 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -7,66 +7,13 @@ namespace App\Http\Controllers; use App\Models\Article; use App\Models\Note; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Response; class FeedsController extends Controller { - /** - * Returns the blog RSS feed. - */ - public function blogRss(): Response - { - $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); - $buildDate = $articles->first()->updated_at->toRssString(); - - return response() - ->view('articles.rss', compact('articles', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); - } - - /** - * Returns the blog Atom feed. - */ - public function blogAtom(): Response - { - $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); - - return response() - ->view('articles.atom', compact('articles')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); - } - - /** - * Returns the notes RSS feed. - */ - public function notesRss(): Response - { - $notes = Note::latest()->take(20)->get(); - $buildDate = $notes->first()->updated_at->toRssString(); - - return response() - ->view('notes.rss', compact('notes', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); - } - - /** - * Returns the notes Atom feed. - */ - public function notesAtom(): Response - { - $notes = Note::latest()->take(20)->get(); - - return response() - ->view('notes.atom', compact('notes')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); - } - - /** @todo sort out return type for json responses */ - /** * Returns the blog JSON feed. */ - public function blogJson(): array + public function blogJson(): JsonResponse { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $data = [ @@ -94,13 +41,15 @@ class FeedsController extends Controller ]; } - return $data; + return response()->json($data, 200, [ + 'Content-Type' => 'application/feed+json', + ]); } /** * Returns the notes JSON feed. */ - public function notesJson(): array + public function notesJson(): JsonResponse { $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); $data = [ @@ -130,7 +79,9 @@ class FeedsController extends Controller } } - return $data; + return response()->json($data, 200, [ + 'Content-Type' => 'application/feed+json', + ]); } /** diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index 45b488da..a795bce8 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Models\MicropubToken; use App\Services\TokenService; -use Exception; -use GuzzleHttp\Client; use GuzzleHttp\Psr7\Uri; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; use Illuminate\View\View; use Random\RandomException; @@ -25,9 +25,10 @@ class IndieAuthController extends Controller 'issuer' => config('app.url'), 'authorization_endpoint' => route('indieauth.start'), 'token_endpoint' => route('indieauth.token'), + 'revocation_endpoint' => route('indieauth.revocation'), + 'introspection_endpoint' => route('indieauth.introspection'), + 'introspection_endpoint_auth_methods_supported' => ['Bearer'], 'code_challenge_methods_supported' => ['S256'], - // 'introspection_endpoint' => route('indieauth.introspection'), - // 'introspection_endpoint_auth_methods_supported' => ['none'], ]); } @@ -179,6 +180,50 @@ class IndieAuthController extends Controller ]); } + /** + * Process a POST request to the IndieAuth revocation endpoint (RFC 7009). + * + * Per spec this always returns HTTP 200, whether the token was revoked, + * unknown, or already revoked, so callers can't probe token validity. + */ + public function processRevocationRequest(Request $request): JsonResponse + { + MicropubToken::findActive($request->get('token'))?->revoke(); + + return response()->json([], 200); + } + + /** + * Process a POST request to the IndieAuth token introspection endpoint + * (RFC 7662, extended by IndieAuth to require the `me` property). + * + * The caller must itself present a currently-active token as a Bearer + * credential to use this endpoint, per spec ("MUST also require some + * form of authorization"). Per spec, an inactive token being introspected + * still gets a 200 response containing only `active: false` - no other + * information about why it's inactive is given. + */ + public function processIntrospectionRequest(Request $request): JsonResponse + { + if (! MicropubToken::findActive($request->bearerToken())) { + return response()->json([], 401); + } + + $token = MicropubToken::findActive($request->get('token')); + + if (! $token) { + return response()->json(['active' => false]); + } + + return response()->json([ + 'active' => true, + 'me' => $token->me, + 'client_id' => $token->client_id, + 'scope' => $token->scope, + 'iat' => $token->created_at->timestamp, + ]); + } + protected function isValidRedirectUri(string $clientId, string $redirectUri): bool { // If client_id is not a valid URL, then it's not valid @@ -199,15 +244,13 @@ class IndieAuthController extends Controller } // Otherwise we need to check the redirect_uri is in the client_id's redirect_uris - $guzzle = resolve(Client::class); - try { - $clientInfo = $guzzle->get($clientId); - } catch (Exception) { + $clientInfo = Http::throw()->get($clientId); + } catch (\Throwable) { return false; } - $clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId); + $clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId); $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index c6008a9c..72242150 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -15,7 +15,6 @@ use App\Services\Micropub\MicropubHandlerRegistry; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Lcobucci\JWT\Token; class MicropubController extends Controller { @@ -71,7 +70,9 @@ class MicropubController extends Controller 'error' => 'invalid_request', 'error_description' => 'No known note with given ID', ], 404); - } catch (MicropubUnsupportedModelException) { + } catch (MicropubUnsupportedModelException $e) { + report($e); + return response()->json([ 'error' => 'invalid', 'error_description' => 'This implementation currently only supports the updating of notes', @@ -81,12 +82,16 @@ class MicropubController extends Controller 'error' => 'invalid_request', 'error_description' => $e->getMessage(), ], 400); - } catch (MicropubHandlerException) { + } catch (MicropubHandlerException $e) { + report($e); + return response()->json([ 'error' => 'unsupported_operation', 'error_description' => 'The request could not be processed by this server', ], 500); - } catch (\Exception $e) { + } catch (\Throwable $e) { + report($e); + return response()->json([ 'error' => 'server_error', 'error_description' => 'An error occurred processing the request', @@ -135,7 +140,7 @@ class MicropubController extends Controller } // the default response is just to return the token data - /** @var Token $tokenData */ + /** @var array $tokenData */ $tokenData = $request->input('token_data'); return response()->json([ diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index 1cca74b3..d9f8ea32 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -13,9 +13,10 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; +use Illuminate\Image\ImageException; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Storage; -use Intervention\Image\ImageManager; use Ramsey\Uuid\Uuid; class MicropubMediaController extends Controller @@ -25,9 +26,7 @@ class MicropubMediaController extends Controller $tokenData = $request->input('token_data'); $scopes = $tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { return (new MicropubResponses)->insufficientScopeResponse(); } @@ -83,9 +82,7 @@ class MicropubMediaController extends Controller $tokenData = $request->input('token_data'); $scopes = $tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { return (new MicropubResponses)->insufficientScopeResponse(); } @@ -111,12 +108,9 @@ class MicropubMediaController extends Controller $filename = Storage::disk('local')->putFile('media', $file); - /** @var ImageManager $manager */ - $manager = resolve(ImageManager::class); try { - $image = $manager->read($request->file('file')); - $width = $image->width(); - } catch (Exception) { + $width = Image::fromUpload($request->file('file'))->width(); + } catch (ImageException) { // not an image $width = null; } diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index b9e55139..e2810f87 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -17,6 +17,8 @@ class LinkHeadersMiddleware $response->header('Link', '<'.route('indieauth.metadata').'>; rel="indieauth-metadata"', false); $response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', false); $response->header('Link', '<'.route('indieauth.token').'>; rel="token_endpoint"', false); + $response->header('Link', '<'.route('indieauth.revocation').'>; rel="revocation_endpoint"', false); + $response->header('Link', '<'.route('indieauth.introspection').'>; rel="introspection_endpoint"', false); $response->header('Link', '<'.route('micropub-endpoint').'>; rel="micropub"', false); $response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false); diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index 33d2cb12..530995ae 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -5,13 +5,9 @@ declare(strict_types=1); namespace App\Http\Middleware; use App\Http\Responses\MicropubResponses; +use App\Models\MicropubToken; use Closure; use Illuminate\Http\Request; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Encoding\CannotDecodeContent; -use Lcobucci\JWT\Token; -use Lcobucci\JWT\Token\InvalidTokenStructure; -use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use Symfony\Component\HttpFoundation\Response; class VerifyMicropubToken @@ -39,15 +35,15 @@ class VerifyMicropubToken ], 401); } - try { - $tokenData = $this->validateToken($rawToken); - } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { + $token = MicropubToken::findActive($rawToken); + + if (! $token) { $micropubResponses = new MicropubResponses; return $micropubResponses->invalidTokenResponse(); } - if ($tokenData->claims()->has('scope') === false) { + if ($token->scope === '') { $micropubResponses = new MicropubResponses; return $micropubResponses->tokenHasNoScopeResponse(); @@ -56,26 +52,10 @@ class VerifyMicropubToken return $next($request->merge([ 'access_token' => $rawToken, 'token_data' => [ - 'me' => $tokenData->claims()->get('me'), - 'scope' => $tokenData->claims()->get('scope'), - 'client_id' => $tokenData->claims()->get('client_id'), + 'me' => $token->me, + 'scope' => $token->scope, + 'client_id' => $token->client_id, ], ])); } - - /** - * Check the token signature is valid. - */ - private function validateToken(string $bearerToken): Token - { - $config = resolve(Configuration::class); - - $token = $config->parser()->parse($bearerToken); - - $constraints = $config->validationConstraints(); - - $config->validator()->assert($token, ...$constraints); - - return $token; - } } diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php index 341c35c8..0cb073d5 100644 --- a/app/Jobs/DownloadWebMention.php +++ b/app/Jobs/DownloadWebMention.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Jobs; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\FileSystem\FileSystem; +use Illuminate\Http\Client\RequestException; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Http; class DownloadWebMention implements ShouldQueue { @@ -29,15 +29,15 @@ class DownloadWebMention implements ShouldQueue /** * Execute the job. * - * @throws GuzzleException + * @throws RequestException * @throws FileNotFoundException */ - public function handle(Client $guzzle): void + public function handle(): void { - $response = $guzzle->request('GET', $this->source); - // 4XX and 5XX responses should get Guzzle to throw an exception, - // Laravel should catch and retry these automatically. - if ($response->getStatusCode() === 200) { + // 4XX and 5XX responses should throw so Laravel can catch and + // retry these automatically. + $response = Http::throw()->get($this->source); + if ($response->status() === 200) { $filesystem = new FileSystem; $filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source); // backup file first @@ -56,7 +56,7 @@ class DownloadWebMention implements ShouldQueue // save new HTML $filesystem->put( $filename, - (string) $response->getBody() + $response->body() ); // remove backup if the same if ($filesystem->exists($filenameBackup)) { diff --git a/app/Jobs/NotifyBrrrOfWebMention.php b/app/Jobs/NotifyBrrrOfWebMention.php new file mode 100644 index 00000000..3273b7d6 --- /dev/null +++ b/app/Jobs/NotifyBrrrOfWebMention.php @@ -0,0 +1,56 @@ + $this->title(), + 'message' => "From {$this->webMention->source}", + 'open_url' => $this->webMention->target, + ]); + } + + /** + * Build a notification title based on the webmention type. + */ + private function title(): string + { + return match ($this->webMention->type) { + 'in-reply-to' => 'New reply', + 'like-of' => 'New like', + 'repost-of' => 'New repost', + default => 'New webmention', + }; + } +} diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php index 49302885..3ed065c1 100644 --- a/app/Jobs/ProcessLike.php +++ b/app/Jobs/ProcessLike.php @@ -5,14 +5,13 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\Like; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Http; use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; @@ -32,13 +31,11 @@ class ProcessLike implements ShouldQueue /** * Execute the job. - * - * @throws GuzzleException */ - public function handle(Client $client, Authorship $authorship): int + public function handle(Authorship $authorship): int { - $response = $client->request('GET', $this->like->url); - $mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url); + $response = Http::throw()->get($this->like->url); + $mf2 = \Mf2\parse($response->body(), $this->like->url); if (Arr::has($mf2, 'items.0.properties.content')) { $this->like->content = $mf2['items'][0]['properties']['content'][0]['html']; } diff --git a/app/Jobs/ProcessMedia.php b/app/Jobs/ProcessMedia.php index f9d8af50..78aeba3e 100644 --- a/app/Jobs/ProcessMedia.php +++ b/app/Jobs/ProcessMedia.php @@ -7,11 +7,11 @@ namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Image\ImageException; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Storage; -use Intervention\Image\Exceptions\DecoderException; -use Intervention\Image\ImageManager; class ProcessMedia implements ShouldQueue { @@ -30,15 +30,16 @@ class ProcessMedia implements ShouldQueue /** * Execute the job. */ - public function handle(ImageManager $manager): void + public function handle(): void { // Load file $file = Storage::disk('local')->get($this->filename); // Open file + $image = Image::fromStorage($this->filename, 'local'); try { - $image = $manager->read($file); - } catch (DecoderException) { + $width = $image->width(); + } catch (ImageException) { // not an image; delete file and end job Storage::disk('local')->delete($this->filename); @@ -49,18 +50,15 @@ class ProcessMedia implements ShouldQueue Storage::disk('public')->put($this->filename, $file); // Create smaller versions if necessary - if ($image->width() > 1000) { + if ($width > 1000) { $filenameParts = explode('.', $this->filename); $extension = array_pop($filenameParts); // the following achieves this data flow // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar $basename = trim(implode('.', $filenameParts), '.'); - $medium = $image->scale(width: 1000); - Storage::disk('public')->put($basename.'-medium.'.$extension, (string) $medium->encode()); - - $small = $image->scale(width: 500); - Storage::disk('public')->put($basename.'-small.'.$extension, (string) $small->encode()); + Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes()); + Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes()); } // Now we can delete the locally saved image diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index 6677b285..4ac6f5fd 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -7,13 +7,12 @@ namespace App\Jobs; use App\Exceptions\RemoteContentNotFoundException; use App\Models\Note; use App\Models\WebMention; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; -use GuzzleHttp\Exception\RequestException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Http; use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException; use Jonnybarnes\WebmentionsParser\Parser; use Mf2; @@ -36,18 +35,20 @@ class ProcessWebMention implements ShouldQueue * Execute the job. * * @throws RemoteContentNotFoundException - * @throws GuzzleException * @throws InvalidMentionException */ - public function handle(Parser $parser, Client $guzzle): void + public function handle(Parser $parser): void { try { - $response = $guzzle->request('GET', $this->source); - } catch (RequestException $e) { + $response = Http::get($this->source); + } catch (ConnectionException) { throw new RemoteContentNotFoundException; } - $this->saveRemoteContent((string) $response->getBody(), $this->source); - $microformats = Mf2\parse((string) $response->getBody(), $this->source); + if ($response->failed()) { + throw new RemoteContentNotFoundException; + } + $this->saveRemoteContent($response->body(), $this->source); + $microformats = Mf2\parse($response->body(), $this->source); $webmentions = WebMention::where('source', $this->source)->get(); foreach ($webmentions as $webmention) { // check webmention still references target @@ -95,6 +96,7 @@ class ProcessWebMention implements ShouldQueue $webmention->type = $type; $webmention->mf2 = json_encode($microformats); $webmention->save(); + dispatch(new NotifyBrrrOfWebMention($webmention)); } /** diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php index 0bcbd4e7..aa7d8af7 100644 --- a/app/Jobs/SaveProfileImage.php +++ b/app/Jobs/SaveProfileImage.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace App\Jobs; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\RequestException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Http; use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; @@ -55,12 +56,10 @@ class SaveProfileImage implements ShouldQueue && parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com' && parse_url($photo, PHP_URL_HOST) !== 'twitter.com' ) { - $client = resolve(Client::class); - try { - $response = $client->get($photo); - $image = $response->getBody(); - } catch (RequestException) { + $response = Http::throw()->get($photo); + $image = $response->body(); + } catch (ConnectionException|RequestException) { // we are opening and reading the default image so that $default = public_path().'/assets/profile-images/default-image'; $handle = fopen($default, 'rb'); diff --git a/app/Jobs/SaveScreenshot.php b/app/Jobs/SaveScreenshot.php index 4661ccfe..b72da7b0 100755 --- a/app/Jobs/SaveScreenshot.php +++ b/app/Jobs/SaveScreenshot.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\Bookmark; -use GuzzleHttp\Client; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; -use JsonException; class SaveScreenshot implements ShouldQueue { @@ -27,77 +28,68 @@ class SaveScreenshot implements ShouldQueue /** * Execute the job. - * - * - * @throws JsonException */ public function handle(): void { - // A normal Guzzle client - $client = resolve(Client::class); - // A Guzzle client with a custom Middleware to retry the CloudConvert API requests - $retryClient = resolve('RetryGuzzle'); + $cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2') + ->withToken(config('services.cloudconvert.token')) + ->throw(); // First request that CloudConvert takes a screenshot of the URL - $takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [ - 'headers' => [ - 'Authorization' => 'Bearer '.config('services.cloudconvert.token'), - ], - 'json' => [ - 'url' => $this->bookmark->url, - 'output_format' => 'png', - 'screen_width' => 1440, - 'screen_height' => 900, - 'wait_until' => 'networkidle0', - 'wait_time' => 100, - ], + $takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [ + 'url' => $this->bookmark->url, + 'output_format' => 'png', + 'screen_width' => 1440, + 'screen_height' => 900, + 'wait_until' => 'networkidle0', + 'wait_time' => 100, ]); - $taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; + $taskId = $takeScreenshotJobResponse->json('data.id'); // Now wait till the status job is finished - $screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$taskId, [ - 'headers' => [ - 'Authorization' => 'Bearer '.config('services.cloudconvert.token'), - ], - 'query' => [ - 'include' => 'payload', - ], - ]); + $screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId); - $finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; + $finishedCaptureId = $screenshotJobStatusResponse->json('data.id'); // Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from - $exportImageJob = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [ - 'headers' => [ - 'Authorization' => 'Bearer '.config('services.cloudconvert.token'), - ], - 'json' => [ - 'input' => $finishedCaptureId, - 'archive_multiple_files' => false, - ], + $exportImageJob = $cloudConvert->post('/export/url', [ + 'input' => $finishedCaptureId, + 'archive_multiple_files' => false, ]); - $exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; + $exportImageJobId = $exportImageJob->json('data.id'); // Again, wait till the status of this export job is finished - $finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$exportImageJobId, [ - 'headers' => [ - 'Authorization' => 'Bearer '.config('services.cloudconvert.token'), - ], - 'query' => [ - 'include' => 'payload', - ], - ]); + $finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId); // Now we can download the screenshot and save it to the storage - $finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url; + $finalImageUrl = $finalImageUrlResponse->json('data.result.files.0.url'); - $finalImageUrlContent = $client->request('GET', $finalImageUrl); + $finalImageUrlContent = Http::throw()->get($finalImageUrl); - Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->getBody()->getContents()); + Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->body()); $this->bookmark->screenshot = $taskId; $this->bookmark->save(); } + + /** + * Poll a CloudConvert task until it reports a "finished" status. + */ + private function pollUntilFinished(PendingRequest $client, string $taskId): Response + { + $attempts = 0; + + do { + $response = $client->get('/tasks/'.$taskId, ['include' => 'payload']); + $finished = $response->json('data.status') === 'finished'; + if (! $finished) { + $attempts++; + usleep(1_000_000); // 1 second, matches CloudConvert's own polling guidance + } + } while (! $finished && $attempts < 5); + + return $response; + } } diff --git a/app/Jobs/SendWebMentions.php b/app/Jobs/SendWebMentions.php index 827aaf0a..d8e962e3 100644 --- a/app/Jobs/SendWebMentions.php +++ b/app/Jobs/SendWebMentions.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\Note; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\Header; use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Psr7\Utils; @@ -14,6 +12,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Mf2\Parser; @@ -32,8 +31,6 @@ class SendWebMentions implements ShouldQueue /** * Execute the job. - * - * @throws GuzzleException */ public function handle(): void { @@ -43,12 +40,9 @@ class SendWebMentions implements ShouldQueue foreach ($urls as $url) { $endpoint = $this->discoverWebmentionEndpoint($url); if ($endpoint !== null) { - $guzzle = resolve(Client::class); - $guzzle->post($endpoint, [ - 'form_params' => [ - 'source' => $this->note->uri, - 'target' => $url, - ], + Http::asForm()->post($endpoint, [ + 'source' => $this->note->uri, + 'target' => $url, ]); } } @@ -56,8 +50,6 @@ class SendWebMentions implements ShouldQueue /** * Discover if a URL has a webmention endpoint. - * - * @throws GuzzleException */ public function discoverWebmentionEndpoint(string $url): ?string { @@ -71,10 +63,9 @@ class SendWebMentions implements ShouldQueue $endpoint = null; - $guzzle = resolve(Client::class); - $response = $guzzle->get($url); + $response = Http::get($url); // check HTTP Headers for webmention endpoint - $links = Header::parse($response->getHeader('Link')); + $links = Header::parse($response->header('Link')); foreach ($links as $link) { if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { return $this->resolveUri(trim($link[0], '<>'), $url); @@ -82,7 +73,7 @@ class SendWebMentions implements ShouldQueue } // failed to find a header so parse HTML - $html = (string) $response->getBody(); + $html = $response->body(); if ($html === '') { return null; diff --git a/app/Jobs/SyndicateNoteToBluesky.php b/app/Jobs/SyndicateNoteToBluesky.php index a306801b..582ef760 100644 --- a/app/Jobs/SyndicateNoteToBluesky.php +++ b/app/Jobs/SyndicateNoteToBluesky.php @@ -5,13 +5,12 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\Note; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Http; class SyndicateNoteToBluesky implements ShouldQueue { @@ -31,29 +30,18 @@ class SyndicateNoteToBluesky implements ShouldQueue /** * Execute the job. - * - * @throws GuzzleException */ - public function handle(Client $guzzle): void + public function handle(): void { - $response = $guzzle->request( - 'POST', - 'https://brid.gy/publish/webmention', - [ - 'headers' => [ - 'Accept' => 'application/json', - ], - 'form_params' => [ - 'source' => $this->note->uri, - 'target' => 'https://brid.gy/publish/bluesky', - ], - 'http_errors' => false, - ] - ); + // no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above + $response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [ + 'source' => $this->note->uri, + 'target' => 'https://brid.gy/publish/bluesky', + ]); - $body = json_decode((string) $response->getBody(), true); + $body = $response->json(); - if ($response->getStatusCode() === 201) { + if ($response->status() === 201) { $this->note->bluesky_url = $body['url']; $this->note->save(); @@ -61,7 +49,7 @@ class SyndicateNoteToBluesky implements ShouldQueue } throw new \RuntimeException( - 'Bridgy publish to Bluesky failed: '.($body['error'] ?? (string) $response->getBody()) + 'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body()) ); } } diff --git a/app/Jobs/SyndicateNoteToMastodon.php b/app/Jobs/SyndicateNoteToMastodon.php index 456680e2..3f5cfcd4 100644 --- a/app/Jobs/SyndicateNoteToMastodon.php +++ b/app/Jobs/SyndicateNoteToMastodon.php @@ -5,13 +5,12 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\Note; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Http; class SyndicateNoteToMastodon implements ShouldQueue { @@ -31,29 +30,18 @@ class SyndicateNoteToMastodon implements ShouldQueue /** * Execute the job. - * - * @throws GuzzleException */ - public function handle(Client $guzzle): void + public function handle(): void { - $response = $guzzle->request( - 'POST', - 'https://brid.gy/publish/webmention', - [ - 'headers' => [ - 'Accept' => 'application/json', - ], - 'form_params' => [ - 'source' => $this->note->uri, - 'target' => 'https://brid.gy/publish/mastodon', - ], - 'http_errors' => false, - ] - ); + // no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above + $response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [ + 'source' => $this->note->uri, + 'target' => 'https://brid.gy/publish/mastodon', + ]); - $body = json_decode((string) $response->getBody(), true); + $body = $response->json(); - if ($response->getStatusCode() === 201) { + if ($response->status() === 201) { $this->note->mastodon_url = $body['url']; $this->note->save(); @@ -61,7 +49,7 @@ class SyndicateNoteToMastodon implements ShouldQueue } throw new \RuntimeException( - 'Bridgy publish to Mastodon failed: '.($body['error'] ?? (string) $response->getBody()) + 'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body()) ); } } diff --git a/app/Models/About.php b/app/Models/About.php new file mode 100644 index 00000000..26b6a719 --- /dev/null +++ b/app/Models/About.php @@ -0,0 +1,13 @@ + [ 'source' => 'title', + 'includeTrashed' => true, ], ]; } @@ -93,6 +94,13 @@ class Article extends Model ); } + protected function uri(): Attribute + { + return Attribute::get( + get: fn () => config('app.url').$this->link, + ); + } + /** * Scope a query to only include articles from a particular year/month. */ diff --git a/app/Models/MicropubToken.php b/app/Models/MicropubToken.php new file mode 100644 index 00000000..c4df41bc --- /dev/null +++ b/app/Models/MicropubToken.php @@ -0,0 +1,50 @@ + 'datetime', + ]; + } + + public function revoke(): void + { + $this->forceFill(['revoked_at' => now()])->save(); + } + + /** + * Find the active (non-revoked) token matching a raw bearer token value. + * + * Accepts mixed because callers pass request input directly, which PHP + * lets be an array (e.g. a client sending token[]=a) - casting that to + * string would throw, so anything non-string is just treated as absent. + */ + public static function findActive(mixed $rawToken): ?self + { + if (! is_string($rawToken) || $rawToken === '') { + return null; + } + + return self::where('token_hash', hash('sha256', $rawToken)) + ->whereNull('revoked_at') + ->first(); + } + + protected function isRevoked(): Attribute + { + return Attribute::make( + get: fn () => $this->revoked_at !== null, + ); + } +} diff --git a/app/Models/Note.php b/app/Models/Note.php index af7d2c3d..89ce6b63 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -7,7 +7,6 @@ namespace App\Models; use App\CommonMark\Generators\MentionGenerator; use App\CommonMark\Renderers\MentionRenderer; use App\Observers\NoteObserver; -use GuzzleHttp\Client; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -20,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Jonnybarnes\IndieWeb\Numbers; use Laravel\Scout\Searchable; use League\CommonMark\Environment\Environment; @@ -363,18 +363,15 @@ class Note extends Model $latLng = $latitude.','.$longitude; return Cache::get($latLng, function () use ($latLng, $latitude, $longitude) { - $guzzle = resolve(Client::class); - $response = $guzzle->request('GET', 'https://nominatim.openstreetmap.org/reverse', [ - 'query' => [ + $response = Http::withHeaders(['User-Agent' => 'jonnybarnes.uk, email jonny@jonnybarnes.uk']) + ->get('https://nominatim.openstreetmap.org/reverse', [ 'format' => 'json', 'lat' => $latitude, 'lon' => $longitude, 'zoom' => 18, 'addressdetails' => 1, - ], - 'headers' => ['User-Agent' => 'jonnybarnes.uk via Guzzle, email jonny@jonnybarnes.uk'], - ]); - $json = json_decode((string) $response->getBody()); + ]); + $json = $response->object(); if (isset($json->address->suburb)) { $locality = $json->address->suburb; if (isset($json->address->city)) { diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 00000000..0c84ea36 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,18 @@ + + */ + protected $casts = [ + 'winter_effect_enabled' => 'boolean', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ba42853e..a40ae43f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,19 +2,13 @@ namespace App\Providers; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\ConnectException; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; +use App\Models\Setting; use Illuminate\Database\Eloquent\Model; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; -use Intervention\Image\ImageManager; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Hmac\Sha256; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Validation\Constraint\SignedWith; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; @@ -33,11 +27,6 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // configure Intervention/Image - $this->app->bind('Intervention\Image\ImageManager', function () { - return ImageManager::withDriver(config('image.driver')); - }); - /** * Paginate a standard Laravel Collection. * @@ -62,17 +51,6 @@ class AppServiceProvider extends ServiceProvider ); }); - // Configure JWT builder - $this->app->bind('Lcobucci\JWT\Configuration', function () { - $key = InMemory::plainText(config('app.key')); - - $config = Configuration::forSymmetricSigner(new Sha256, $key); - - $config->setValidationConstraints(new SignedWith(new Sha256, $key)); - - return $config; - }); - // Configure HtmlSanitizer $this->app->bind(HtmlSanitizer::class, function () { return new HtmlSanitizer( @@ -82,39 +60,15 @@ class AppServiceProvider extends ServiceProvider ); }); - // Configure Guzzle - $this->app->bind('RetryGuzzle', function () { - $handlerStack = HandlerStack::create(); - $handlerStack->push(Middleware::retry( - function ($retries, $request, $response, $exception) { - // Limit the number of retries to 5 - if ($retries >= 5) { - return false; - } - - // Retry connection exceptions - if ($exception instanceof ConnectException) { - return true; - } - - // Retry on server errors - if ($response && $response->getStatusCode() >= 500) { - return true; - } - - // Finally for CloudConvert, retry if status is not final - return json_decode($response, false, 512, JSON_THROW_ON_ERROR)->data->status !== 'finished'; - }, - function () { - // Retry after 1 second - return 1000; - } - )); - - return new Client(['handler' => $handlerStack]); - }); - // Turn on Eloquent strict mode when developing Model::shouldBeStrict(! $this->app->isProduction()); + + // Force HTTPS URL generation in production + URL::forceHttps($this->app->isProduction()); + + // Share whether the winter snow effect is enabled with the base layout + View::composer('master', function ($view) { + $view->with('winterEffectEnabled', Setting::first()?->winter_effect_enabled ?? false); + }); } } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 3d5dcc56..ab91f9e4 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -8,12 +8,29 @@ use App\Models\Article; class ArticleService { + /** + * @throws \InvalidArgumentException if a published article already has this title + */ public function create(array $data): Article { - return Article::create([ + $attributes = [ 'title' => $data['name'], 'main' => $data['content'], - 'published' => true, - ]); + 'published' => ($data['post-status'] ?? null) !== 'draft', + ]; + + $existing = Article::where('title', $data['name'])->first(); + + if ($existing !== null) { + if ($existing->published) { + throw new \InvalidArgumentException("An article titled \"{$data['name']}\" has already been published"); + } + + $existing->update($attributes); + + return $existing; + } + + return Article::create($attributes); } } diff --git a/app/Services/BookmarkService.php b/app/Services/BookmarkService.php index 25017e16..b873610f 100644 --- a/app/Services/BookmarkService.php +++ b/app/Services/BookmarkService.php @@ -8,10 +8,8 @@ use App\Exceptions\InternetArchiveException; use App\Jobs\ProcessBookmark; use App\Models\Bookmark; use App\Models\Tag; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\ClientException; -use GuzzleHttp\Exception\GuzzleException; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class BookmarkService @@ -55,21 +53,19 @@ class BookmarkService * Given a URL, attempt to save it to the Internet Archive. * * @throws InternetArchiveException - * @throws GuzzleException */ public function getArchiveLink(string $url): string { - $client = resolve(Client::class); - try { - $response = $client->request('GET', 'https://web.archive.org/save/'.$url); - } catch (ClientException $e) { + $response = Http::get('https://web.archive.org/save/'.$url); + + if ($response->clientError()) { // throw an exception to be caught throw new InternetArchiveException; } - if ($response->hasHeader('Content-Location')) { - if (Str::startsWith(Arr::get($response->getHeader('Content-Location'), 0), '/web')) { - return $response->getHeader('Content-Location')[0]; - } + + $contentLocation = $response->header('Content-Location'); + if ($contentLocation !== '' && Str::startsWith($contentLocation, '/web')) { + return $contentLocation; } // throw an exception to be caught diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index 52dde72d..192c50f7 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -24,6 +24,7 @@ class EntryData extends MicropubData public readonly mixed $checkin, public readonly mixed $syndication, public readonly ?array $photos, + public readonly ?string $postStatus, ) {} public static function fromRequest(Request $request): static @@ -49,6 +50,7 @@ class EntryData extends MicropubData checkin: Arr::get($data, 'properties.checkin.0'), syndication: Arr::get($data, 'properties.syndication.0'), photos: Arr::get($data, 'properties.photo'), + postStatus: Arr::get($data, 'properties.post-status.0'), ); } @@ -67,6 +69,7 @@ class EntryData extends MicropubData checkin: $request->input('checkin'), syndication: $request->input('syndication'), photos: $request->input('photos'), + postStatus: $request->input('post-status'), ); } @@ -100,6 +103,7 @@ class EntryData extends MicropubData checkin: $data['checkin'] ?? null, syndication: $data['syndication'] ?? null, photos: $data['photos'] ?? null, + postStatus: $data['post-status'] ?? null, ); } @@ -120,6 +124,7 @@ class EntryData extends MicropubData 'checkin' => $this->checkin, 'syndication' => $this->syndication, 'photos' => $this->photos, + 'post-status' => $this->postStatus, ]; } } diff --git a/app/Services/Micropub/Handlers/CardHandler.php b/app/Services/Micropub/Handlers/CardHandler.php index 02e3a066..6b24f21b 100644 --- a/app/Services/Micropub/Handlers/CardHandler.php +++ b/app/Services/Micropub/Handlers/CardHandler.php @@ -24,9 +24,7 @@ class CardHandler implements MicropubHandlerInterface assert($data instanceof CardData); $scopes = $data->tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index ef9740f2..d79a0418 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -27,9 +27,7 @@ class EntryHandler implements MicropubHandlerInterface assert($data instanceof EntryData); $scopes = $data->tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { throw new InvalidTokenScopeException; @@ -39,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface $location = match (true) { isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, - isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link, + isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri, default => resolve(NoteService::class)->create($dataArray)->uri, }; diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 49f86063..136a0840 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -30,9 +30,7 @@ class UpdateHandler implements MicropubHandlerInterface assert($data instanceof UpdateData); $scopes = $data->tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('update', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/app/Services/TokenService.php b/app/Services/TokenService.php index 68a9293b..2941c28b 100644 --- a/app/Services/TokenService.php +++ b/app/Services/TokenService.php @@ -5,28 +5,26 @@ declare(strict_types=1); namespace App\Services; use App\Jobs\AddClientToDatabase; -use DateTimeImmutable; -use Lcobucci\JWT\Configuration; +use App\Models\MicropubToken; class TokenService { /** - * Generate a JWT token. + * Generate a new bearer token. */ public function getNewToken(array $data): string { - $config = resolve(Configuration::class); + $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); - $token = $config->builder() - ->issuedAt(new DateTimeImmutable) - ->withClaim('client_id', $data['client_id']) - ->withClaim('me', $data['me']) - ->withClaim('scope', $data['scope']) - ->withClaim('nonce', bin2hex(random_bytes(8))) - ->getToken($config->signer(), $config->signingKey()); + MicropubToken::create([ + 'token_hash' => hash('sha256', $token), + 'client_id' => $data['client_id'], + 'me' => $data['me'], + 'scope' => $data['scope'], + ]); dispatch(new AddClientToDatabase($data['client_id'])); - return $token->toString(); + return $token; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 9c73bdb9..e29a3598 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,8 +17,10 @@ return Application::configure(basePath: dirname(__DIR__)) ->append(LinkHeadersMiddleware::class) ->preventRequestForgery( except: [ - 'auth', // This is the IndieAuth auth endpoint - 'token', // This is the IndieAuth token endpoint + 'auth', // This is the IndieAuth auth endpoint + 'token', // This is the IndieAuth token endpoint + 'revocation', // This is the IndieAuth revocation endpoint + 'introspect', // This is the IndieAuth introspection endpoint 'api/post', 'api/media', 'micropub/places', diff --git a/composer.json b/composer.json index 1b94abe8..5871ee02 100644 --- a/composer.json +++ b/composer.json @@ -14,20 +14,18 @@ "ext-pgsql": "*", "ext-sodium": "*", "cviebrock/eloquent-sluggable": "^13.0", - "guzzlehttp/guzzle": "^7.2", "indieauth/client": "^1.1", - "intervention/image": "^3", + "intervention/image": "^4.0", "jonnybarnes/indieweb": "~0.2", "jonnybarnes/webmentions-parser": "~0.5", "laravel/framework": "^13.0", "laravel/horizon": "^5.0", "laravel/scout": "^10.1", "laravel/tinker": "^3.0", - "lcobucci/jwt": "^5.0", "league/commonmark": "^2.0", "league/flysystem-aws-s3-v3": "^3.0", "mf2/mf2": "~0.3", - "spatie/laravel-flare": "^2.2", + "spatie/laravel-flare": "^3.0", "symfony/html-sanitizer": "^8.0", "tempest/highlight": "^2.27", "web-auth/webauthn-lib": "^5.0" @@ -42,8 +40,8 @@ "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", - "phpunit/php-code-coverage": "^12.0", - "phpunit/phpunit": "^12.5.12", + "phpunit/php-code-coverage": "^14.0", + "phpunit/phpunit": "^13.0", "spatie/laravel-ray": "^1.12", "spatie/x-ray": "^1.2" }, diff --git a/composer.lock b/composer.lock index 65ec1857..4c98c2cf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "30db4db4dcfdc487f28e738f0d22d68c", + "content-hash": "a2842cf95580a08ad759a92d74fae3b4", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.386.1", + "version": "3.389.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a" + "reference": "e6e6649e58826c7edaa9f546f444461a25a22719" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e36bc0e97e82d68acc92b9e06f5dc544913d819a", - "reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e6e6649e58826c7edaa9f546f444461a25a22719", + "reference": "e6e6649e58826c7edaa9f546f444461a25a22719", "shasum": "" }, "require": { @@ -79,9 +79,9 @@ "ext-json": "*", "ext-pcre": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/promises": "^2.0", - "guzzlehttp/psr7": "^2.4.5", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", "mtdowling/jmespath.php": "^2.9.1", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", @@ -153,22 +153,22 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.386.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.389.0" }, - "time": "2026-06-23T01:24:07+00:00" + "time": "2026-07-24T18:05:53+00:00" }, { "name": "brick/math", - "version": "0.17.2", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", - "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "shasum": "" }, "require": { @@ -206,7 +206,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.2" + "source": "https://github.com/brick/math/tree/0.18.0" }, "funding": [ { @@ -214,7 +214,7 @@ "type": "github" } ], - "time": "2026-05-25T20:34:43+00:00" + "time": "2026-06-14T18:21:03+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -361,16 +361,16 @@ }, { "name": "cviebrock/eloquent-sluggable", - "version": "13.0.0", + "version": "13.0.1", "source": { "type": "git", "url": "https://github.com/cviebrock/eloquent-sluggable.git", - "reference": "0a023eed5bdc7da3aa9107cb2299757ce4a656e3" + "reference": "a0e2e342461ac04f5c41eb1c52f9bc77eafa6a03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cviebrock/eloquent-sluggable/zipball/0a023eed5bdc7da3aa9107cb2299757ce4a656e3", - "reference": "0a023eed5bdc7da3aa9107cb2299757ce4a656e3", + "url": "https://api.github.com/repos/cviebrock/eloquent-sluggable/zipball/a0e2e342461ac04f5c41eb1c52f9bc77eafa6a03", + "reference": "a0e2e342461ac04f5c41eb1c52f9bc77eafa6a03", "shasum": "" }, "require": { @@ -422,7 +422,7 @@ ], "support": { "issues": "https://github.com/cviebrock/eloquent-sluggable/issues", - "source": "https://github.com/cviebrock/eloquent-sluggable/tree/13.0.0" + "source": "https://github.com/cviebrock/eloquent-sluggable/tree/13.0.1" }, "funding": [ { @@ -430,7 +430,7 @@ "type": "github" } ], - "time": "2026-03-19T14:42:10+00:00" + "time": "2026-07-20T13:44:42+00:00" }, { "name": "dflydev/dot-access-data", @@ -988,22 +988,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.13.0", + "version": "7.15.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "a4decaa9745dc567467970e43f183e260cfa51fd" + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/a4decaa9745dc567467970e43f183e260cfa51fd", - "reference": "a4decaa9745dc567467970e43f183e260cfa51fd", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.3", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1015,8 +1015,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.6", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1096,7 +1096,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.13.0" + "source": "https://github.com/guzzle/guzzle/tree/7.15.1" }, "funding": [ { @@ -1112,20 +1112,20 @@ "type": "tidelift" } ], - "time": "2026-06-29T13:31:06+00:00" + "time": "2026-07-18T11:23:11+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -1180,7 +1180,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1196,20 +1196,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.3", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -1299,7 +1299,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.3" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -1315,20 +1315,20 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:21:08+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.8", + "version": "v1.0.10", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", "shasum": "" }, "require": { @@ -1385,7 +1385,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" }, "funding": [ { @@ -1401,7 +1401,7 @@ "type": "tidelift" } ], - "time": "2026-06-23T13:02:23+00:00" + "time": "2026-07-17T13:53:03+00:00" }, { "name": "indieauth/client", @@ -1556,26 +1556,26 @@ }, { "name": "intervention/gif", - "version": "4.2.4", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/Intervention/gif.git", - "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" + "reference": "bb395af960deffe64d70c976b4df9283f68e762d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", - "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", + "url": "https://api.github.com/repos/Intervention/gif/zipball/bb395af960deffe64d70c976b4df9283f68e762d", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.3" }, "require-dev": { "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "phpunit/phpunit": "^12.0", "slevomat/coding-standard": "~8.0", - "squizlabs/php_codesniffer": "^3.8" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "autoload": { @@ -1594,7 +1594,7 @@ "homepage": "https://intervention.io/" } ], - "description": "Native PHP GIF Encoder/Decoder", + "description": "PHP GIF Encoder/Decoder", "homepage": "https://github.com/intervention/gif", "keywords": [ "animation", @@ -1604,7 +1604,7 @@ ], "support": { "issues": "https://github.com/Intervention/gif/issues", - "source": "https://github.com/Intervention/gif/tree/4.2.4" + "source": "https://github.com/Intervention/gif/tree/5.0.1" }, "funding": [ { @@ -1620,31 +1620,31 @@ "type": "ko_fi" } ], - "time": "2026-01-04T09:27:23+00:00" + "time": "2026-05-03T06:04:47+00:00" }, { "name": "intervention/image", - "version": "3.11.8", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "cf04c8dd245697f701057c13d4bfe140d584e738" + "reference": "830907fc5397dfc2a51a4e90322d586989fc8364" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/cf04c8dd245697f701057c13d4bfe140d584e738", - "reference": "cf04c8dd245697f701057c13d4bfe140d584e738", + "url": "https://api.github.com/repos/Intervention/image/zipball/830907fc5397dfc2a51a4e90322d586989fc8364", + "reference": "830907fc5397dfc2a51a4e90322d586989fc8364", "shasum": "" }, "require": { "ext-mbstring": "*", - "intervention/gif": "^4.2", - "php": "^8.1" + "intervention/gif": "^5", + "php": "^8.3" }, "require-dev": { "mockery/mockery": "^1.6", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "phpunit/phpunit": "^12.0", "slevomat/coding-standard": "~8.0", "squizlabs/php_codesniffer": "^4" }, @@ -1680,7 +1680,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.8" + "source": "https://github.com/Intervention/image/tree/4.2.0" }, "funding": [ { @@ -1696,7 +1696,7 @@ "type": "ko_fi" } ], - "time": "2026-05-01T08:20:10+00:00" + "time": "2026-07-09T13:07:14+00:00" }, { "name": "jonnybarnes/indieweb", @@ -1801,16 +1801,16 @@ }, { "name": "laravel/framework", - "version": "v13.17.0", + "version": "v13.22.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "0802b7a81f3252d78200b8037ac183a686a529f0" + "reference": "d354afa45334cdac523fbc015f203a78d7e2f5c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/0802b7a81f3252d78200b8037ac183a686a529f0", - "reference": "0802b7a81f3252d78200b8037ac183a686a529f0", + "url": "https://api.github.com/repos/laravel/framework/zipball/d354afa45334cdac523fbc015f203a78d7e2f5c6", + "reference": "d354afa45334cdac523fbc015f203a78d7e2f5c6", "shasum": "" }, "require": { @@ -1889,6 +1889,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/image": "self.version", "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", @@ -1915,6 +1916,7 @@ "ext-gmp": "*", "fakerphp/faker": "^1.24", "guzzlehttp/psr7": "^2.9", + "intervention/image": "^4.0", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -1951,6 +1953,7 @@ "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.0).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", @@ -2021,20 +2024,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-06-23T19:42:45+00:00" + "time": "2026-07-24T20:38:48+00:00" }, { "name": "laravel/horizon", - "version": "v5.47.2", + "version": "v5.48.1", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "a6ac142293ad02db4d7cccb961dd32f56ef1462d" + "reference": "7953b21becabb83974ab93111ffa4d9439dcac07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/a6ac142293ad02db4d7cccb961dd32f56ef1462d", - "reference": "a6ac142293ad02db4d7cccb961dd32f56ef1462d", + "url": "https://api.github.com/repos/laravel/horizon/zipball/7953b21becabb83974ab93111ffa4d9439dcac07", + "reference": "7953b21becabb83974ab93111ffa4d9439dcac07", "shasum": "" }, "require": { @@ -2099,9 +2102,9 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.47.2" + "source": "https://github.com/laravel/horizon/tree/v5.48.1" }, - "time": "2026-06-03T15:11:37+00:00" + "time": "2026-07-20T15:53:58+00:00" }, { "name": "laravel/prompts", @@ -2300,16 +2303,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.13", + "version": "v2.0.15", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", "shasum": "" }, "require": { @@ -2357,7 +2360,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-04-16T14:03:50+00:00" + "time": "2026-07-21T16:49:22+00:00" }, { "name": "laravel/tinker", @@ -2428,91 +2431,18 @@ }, "time": "2026-03-17T14:54:13+00:00" }, - { - "name": "lcobucci/jwt", - "version": "5.6.0", - "source": { - "type": "git", - "url": "https://github.com/lcobucci/jwt.git", - "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", - "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "ext-sodium": "*", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "psr/clock": "^1.0" - }, - "require-dev": { - "infection/infection": "^0.29", - "lcobucci/clock": "^3.2", - "lcobucci/coding-standard": "^11.0", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.2", - "phpstan/phpstan": "^1.10.7", - "phpstan/phpstan-deprecation-rules": "^1.1.3", - "phpstan/phpstan-phpunit": "^1.3.10", - "phpstan/phpstan-strict-rules": "^1.5.0", - "phpunit/phpunit": "^11.1" - }, - "suggest": { - "lcobucci/clock": ">= 3.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Lcobucci\\JWT\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Luís Cobucci", - "email": "lcobucci@gmail.com", - "role": "Developer" - } - ], - "description": "A simple library to work with JSON Web Token and JSON Web Signature", - "keywords": [ - "JWS", - "jwt" - ], - "support": { - "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/5.6.0" - }, - "funding": [ - { - "url": "https://github.com/lcobucci", - "type": "github" - }, - { - "url": "https://www.patreon.com/lcobucci", - "type": "patreon" - } - ], - "time": "2025-10-17T11:30:53+00:00" - }, { "name": "league/commonmark", - "version": "2.8.2", + "version": "2.8.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", "shasum": "" }, "require": { @@ -2534,8 +2464,8 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", @@ -2606,7 +2536,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-07-12T15:29:16+00:00" }, { "name": "league/config", @@ -2692,16 +2622,16 @@ }, { "name": "league/flysystem", - "version": "3.35.1", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -2769,22 +2699,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2026-06-25T06:52:23+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.35.1", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94" + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", - "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4", "shasum": "" }, "require": { @@ -2824,9 +2754,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.1" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2" }, - "time": "2026-06-25T06:51:08+00:00" + "time": "2026-07-01T23:25:49+00:00" }, { "name": "league/flysystem-local", @@ -2879,16 +2809,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -2898,7 +2828,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -2919,7 +2849,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -2931,7 +2861,7 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/uri", @@ -3282,16 +3212,16 @@ }, { "name": "mtdowling/jmespath.php", - "version": "2.9.1", + "version": "2.9.2", "source": { "type": "git", "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34" + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34", - "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", "shasum": "" }, "require": { @@ -3342,22 +3272,22 @@ ], "support": { "issues": "https://github.com/jmespath/jmespath.php/issues", - "source": "https://github.com/jmespath/jmespath.php/tree/2.9.1" + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" }, - "time": "2026-06-11T10:43:56+00:00" + "time": "2026-07-06T18:56:19+00:00" }, { "name": "nesbot/carbon", - "version": "3.13.0", + "version": "3.13.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", "shasum": "" }, "require": { @@ -3449,7 +3379,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T13:49:15+00:00" + "time": "2026-07-09T18:23:49+00:00" }, { "name": "nette/schema", @@ -3520,16 +3450,16 @@ }, { "name": "nette/utils", - "version": "v4.1.4", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { @@ -3549,7 +3479,7 @@ }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", @@ -3605,26 +3535,25 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.4" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2026-05-11T20:49:54+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -3663,9 +3592,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/termwind", @@ -4118,16 +4047,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -4159,9 +4088,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "psr/clock", @@ -4916,99 +4845,29 @@ ], "time": "2026-03-11T13:48:28+00:00" }, - { - "name": "spatie/error-solutions", - "version": "2.0.5", - "source": { - "type": "git", - "url": "https://github.com/spatie/error-solutions.git", - "reference": "0392eff7f36f01249a6e9f8eb22a7d54651881bc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/error-solutions/zipball/0392eff7f36f01249a6e9f8eb22a7d54651881bc", - "reference": "0392eff7f36f01249a6e9f8eb22a7d54651881bc", - "shasum": "" - }, - "require": { - "php": "^8.2" - }, - "require-dev": { - "illuminate/broadcasting": "^10.0|^11.0|^12.0|^13.0", - "illuminate/cache": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "livewire/livewire": "^2.11|^3.5.20|^4.0", - "openai-php/client": "^0.13.0", - "orchestra/testbench": "8.22.3|^9.0|^10.0|^11.0", - "pestphp/pest": "^2.20|^3.0", - "phpstan/phpstan": "^2.1", - "psr/simple-cache": "^3.0", - "psr/simple-cache-implementation": "^3.0", - "spatie/ray": "^1.28", - "symfony/cache": "^5.4|^6.0|^7.0|^8.0", - "symfony/process": "^5.4|^6.0|^7.0|^8.0", - "vlucas/phpdotenv": "^5.5" - }, - "suggest": { - "openai-php/client": "Require get solutions from OpenAI", - "simple-cache-implementation": "To cache solutions from OpenAI" - }, - "type": "library", - "autoload": { - "psr-4": { - "Spatie\\ErrorSolutions\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ruben Van Assche", - "email": "ruben@spatie.be", - "role": "Developer" - } - ], - "description": "This is my package error-solutions", - "homepage": "https://github.com/spatie/error-solutions", - "keywords": [ - "error-solutions", - "spatie" - ], - "support": { - "issues": "https://github.com/spatie/error-solutions/issues", - "source": "https://github.com/spatie/error-solutions/tree/2.0.5" - }, - "funding": [ - { - "url": "https://github.com/Spatie", - "type": "github" - } - ], - "time": "2026-02-27T15:32:49+00:00" - }, { "name": "spatie/flare-client-php", - "version": "2.10.2", + "version": "3.3.1", "source": { "type": "git", "url": "https://github.com/spatie/flare-client-php.git", - "reference": "056aa9cb10e41d7490d51d2f15875143e6d12ce2" + "reference": "e045814eefd9f5862ec69aec6fc53b03838e2c92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/056aa9cb10e41d7490d51d2f15875143e6d12ce2", - "reference": "056aa9cb10e41d7490d51d2f15875143e6d12ce2", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/e045814eefd9f5862ec69aec6fc53b03838e2c92", + "reference": "e045814eefd9f5862ec69aec6fc53b03838e2c92", "shasum": "" }, "require": { "ext-curl": "*", - "guzzlehttp/guzzle": "^7.9", - "php": "^8.2", + "guzzlehttp/guzzle": "^7.9|^8.0", + "monolog/monolog": "^3.0", + "php": "^8.1", "psr/container": "^2.0", "spatie/backtrace": "^1.8.0", - "spatie/error-solutions": "^2.0", + "spatie/flare-daemon": "^0.3", + "symfony/console": "^5.2|^6.0|^7.0|^8.0", "symfony/http-foundation": "^5.2|^6.0|^7.0|^8.0", "symfony/mime": "^5.2|^6.0|^7.0|^8.0", "symfony/process": "^5.2|^6.0|^7.0|^8.0", @@ -5018,14 +4877,16 @@ "friendsofphp/php-cs-fixer": "^3.89", "pestphp/pest": "^2.36.0|^3.0|^4.0", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan-deprecation-rules": "^1.2", - "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "spatie/invade": "^2.1", "spatie/pest-plugin-snapshots": "^2.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.3.x-dev" + "dev-main": "3.x-dev" } }, "autoload": { @@ -5047,7 +4908,7 @@ ], "support": { "issues": "https://github.com/spatie/flare-client-php/issues", - "source": "https://github.com/spatie/flare-client-php/tree/2.10.2" + "source": "https://github.com/spatie/flare-client-php/tree/3.3.1" }, "funding": [ { @@ -5055,25 +4916,94 @@ "type": "github" } ], - "time": "2026-02-23T14:14:53+00:00" + "time": "2026-07-22T06:40:58+00:00" }, { - "name": "spatie/laravel-error-share", - "version": "1.0.9", + "name": "spatie/flare-daemon", + "version": "0.3.0", "source": { "type": "git", - "url": "https://github.com/spatie/laravel-error-share.git", - "reference": "52e65ee35a7f782e27460cb307cc97cec8332793" + "url": "https://github.com/spatie/flare-daemon.git", + "reference": "dfa1d156aac231a619f78974c983a78391e1e034" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-error-share/zipball/52e65ee35a7f782e27460cb307cc97cec8332793", - "reference": "52e65ee35a7f782e27460cb307cc97cec8332793", + "url": "https://api.github.com/repos/spatie/flare-daemon/zipball/dfa1d156aac231a619f78974c983a78391e1e034", + "reference": "dfa1d156aac231a619f78974c983a78391e1e034", "shasum": "" }, "require": { - "illuminate/contracts": "^12.50|^13.0", - "php": "^8.2", + "composer-runtime-api": "^2.2", + "ext-zlib": "*", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.0", + "pestphp/pest": "^3.0 || ^4.0", + "phpstan/phpstan": "^2.1", + "react/async": "^4.3", + "spatie/flare-daemon-runtime": "*", + "spatie/ray": "^1.28", + "symfony/process": "^7.2" + }, + "bin": [ + "bin/flare-daemon" + ], + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\FlareDaemon\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "role": "Developer" + } + ], + "description": "Local HTTP daemon for asynchronous Flare delivery", + "homepage": "https://github.com/spatie/flare-daemon", + "keywords": [ + "error-tracking", + "flare", + "flare-daemon", + "performance-monitoring", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/flare-daemon/issues", + "source": "https://github.com/spatie/flare-daemon/tree/0.3.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-07-02T15:29:10+00:00" + }, + { + "name": "spatie/laravel-error-share", + "version": "1.0.10", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-error-share.git", + "reference": "f13247bff7d7849e5279cf78777191f53cb12568" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-error-share/zipball/f13247bff7d7849e5279cf78777191f53cb12568", + "reference": "f13247bff7d7849e5279cf78777191f53cb12568", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.50|^13.0", + "php": "^8.1", "spatie/laravel-package-tools": "^1.16" }, "require-dev": { @@ -5129,7 +5059,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-error-share/issues", - "source": "https://github.com/spatie/laravel-error-share/tree/1.0.9" + "source": "https://github.com/spatie/laravel-error-share/tree/1.0.10" }, "funding": [ { @@ -5137,51 +5067,46 @@ "type": "github" } ], - "time": "2026-05-11T08:16:59+00:00" + "time": "2026-07-03T07:49:45+00:00" }, { "name": "spatie/laravel-flare", - "version": "2.8.0", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-flare.git", - "reference": "c2722a270ba2653baef2c9e29b893e28955717ce" + "reference": "6ef59eeba3bdf7561be849919f6058ad484ed151" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-flare/zipball/c2722a270ba2653baef2c9e29b893e28955717ce", - "reference": "c2722a270ba2653baef2c9e29b893e28955717ce", + "url": "https://api.github.com/repos/spatie/laravel-flare/zipball/6ef59eeba3bdf7561be849919f6058ad484ed151", + "reference": "6ef59eeba3bdf7561be849919f6058ad484ed151", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "illuminate/support": "^11.46|^12.0|^13.0", + "illuminate/support": "^10.0|^11.47|^12.42|^13.0", "php": "^8.2", - "spatie/error-solutions": "^2.0", - "spatie/flare-client-php": "^2.10.2", + "spatie/flare-client-php": "^3.3.0", "spatie/laravel-error-share": "^1.0.3", - "symfony/console": "^7.2.1|^8.0", - "symfony/var-dumper": "^7.2.3|^8.0" + "symfony/console": "^6.4|^7.2.1|^8.0", + "symfony/var-dumper": "^6.4|^7.2.3|^8.0" }, "require-dev": { - "laravel/serializable-closure": "^2.0", - "livewire/livewire": "^3.6.0|^4.0", + "laravel/serializable-closure": "^1.3|^2.0", + "livewire/livewire": "^3.6.0|^4.2", "mockery/mockery": "^1.6.12", - "openai-php/client": "^0.8.5", - "orchestra/testbench": "^9.14|^10.8|^11.0", + "orchestra/testbench": "^8.31|^9.14|^10.8|^11.0", "pestphp/pest": "^2.34|^3.7.4|^4.1", "pestphp/pest-plugin-laravel": "^2.4|^3.0|^4.0", "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan-deprecation-rules": "^1.1.1|^2.0.1", "phpstan/phpstan-phpunit": "^1.3.16|^2.0.4", + "spatie/ray": "^1.45", "vlucas/phpdotenv": "^5.6.1" }, - "suggest": { - "openai-php/client": "Required to get solutions from OpenAI", - "psr/simple-cache-implementation": "Used to cache solutions from OpenAI" - }, "type": "library", "extra": { "laravel": { @@ -5228,7 +5153,7 @@ "type": "github" } ], - "time": "2026-04-08T13:26:20+00:00" + "time": "2026-07-15T08:06:53+00:00" }, { "name": "spatie/laravel-package-tools", @@ -5293,20 +5218,20 @@ }, { "name": "spomky-labs/cbor-php", - "version": "3.2.3", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", - "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", "ext-mbstring": "*", "php": ">=8.0" }, @@ -5348,7 +5273,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" }, "funding": [ { @@ -5360,45 +5285,44 @@ "type": "patreon" } ], - "time": "2026-04-01T12:15:20+00:00" + "time": "2026-07-15T18:56:27+00:00" }, { "name": "spomky-labs/pki-framework", - "version": "1.4.2", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + "reference": "e0d61661962560c1cedfef02b51b431e720aae78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", - "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/e0d61661962560c1cedfef02b51b431e720aae78", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", "ext-mbstring": "*", - "php": ">=8.1", - "psr/clock": "^1.0" + "php": ">=8.1" }, "require-dev": { "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-gmp": "*", "ext-openssl": "*", - "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "infection/infection": "^0.28|^0.29|^0.31", "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/extension-installer": "^1.3|^2.0", "phpstan/phpstan": "^1.8|^2.0", "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", "phpstan/phpstan-phpunit": "^1.1|^2.0", "phpstan/phpstan-strict-rules": "^1.3|^2.0", - "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", "symfony/string": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symplify/easy-coding-standard": "^12.0|^13.0" + "symplify/easy-coding-standard": "^12.0 || ^13.0" }, "suggest": { "ext-bcmath": "For better performance (or GMP)", @@ -5458,7 +5382,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.5.0" }, "funding": [ { @@ -5470,7 +5394,7 @@ "type": "patreon" } ], - "time": "2026-03-23T22:56:56+00:00" + "time": "2026-07-16T10:28:45+00:00" }, { "name": "symfony/clock", @@ -8638,16 +8562,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -8706,7 +8630,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -8718,7 +8642,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { "name": "voku/portable-ascii", @@ -8796,20 +8720,20 @@ }, { "name": "web-auth/cose-lib", - "version": "4.5.2", + "version": "4.6.0", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d" + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d", - "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", "ext-json": "*", "ext-openssl": "*", "php": ">=8.1", @@ -8851,7 +8775,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.5.2" + "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" }, "funding": [ { @@ -8863,7 +8787,7 @@ "type": "patreon" } ], - "time": "2026-05-03T09:49:50+00:00" + "time": "2026-07-16T10:19:49+00:00" }, { "name": "web-auth/webauthn-lib", @@ -9021,16 +8945,16 @@ "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v4.3.0", + "version": "v4.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/laravel-debugbar.git", - "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c" + "reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/3d76ea8d78b82225b92789de65fc630c1cd8e80c", - "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c", + "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/80ef956bda9e1a5824037d6f2cd06e73092e5634", + "reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634", "shasum": "" }, "require": { @@ -9038,11 +8962,12 @@ "illuminate/session": "^11|^12|^13.0", "illuminate/support": "^11|^12|^13.0", "php": "^8.2", - "php-debugbar/php-debugbar": "^3.7.2", + "php-debugbar/php-debugbar": "^3.8.0", "php-debugbar/symfony-bridge": "^1.1" }, "require-dev": { "larastan/larastan": "^3", + "laravel/ai": "^0.8", "laravel/octane": "^2", "laravel/pennant": "^1", "laravel/pint": "^1", @@ -9104,7 +9029,7 @@ ], "support": { "issues": "https://github.com/fruitcake/laravel-debugbar/issues", - "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.3.0" + "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.4.0" }, "funding": [ { @@ -9116,7 +9041,7 @@ "type": "github" } ], - "time": "2026-06-04T07:54:01+00:00" + "time": "2026-07-04T08:30:57+00:00" }, { "name": "barryvdh/laravel-ide-helper", @@ -9819,16 +9744,16 @@ }, { "name": "laravel/sail", - "version": "v1.63.0", + "version": "v1.64.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc" + "reference": "08cacd3e72d6798df3fa8bd4b5d55d0f7f920625" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/51bbce3f803c1d386cabbb44e618c955a12ff5fc", - "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc", + "url": "https://api.github.com/repos/laravel/sail/zipball/08cacd3e72d6798df3fa8bd4b5d55d0f7f920625", + "reference": "08cacd3e72d6798df3fa8bd4b5d55d0f7f920625", "shasum": "" }, "require": { @@ -9878,7 +9803,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-06-18T08:54:14+00:00" + "time": "2026-07-17T15:09:35+00:00" }, { "name": "mockery/mockery", @@ -10025,23 +9950,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.4", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.8 || ^8.0.8" + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -10049,12 +9974,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.6", - "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", - "laravel/pint": "^1.29.1", - "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", - "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -10117,7 +10042,7 @@ "type": "patreon" } ], - "time": "2026-04-21T14:04:20+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "permafrost-dev/code-snippets", @@ -10366,16 +10291,16 @@ }, { "name": "php-debugbar/php-debugbar", - "version": "v3.7.6", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/php-debugbar/php-debugbar.git", - "reference": "1690ee1728827f9deb4b60457fa387cf44672c56" + "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/1690ee1728827f9deb4b60457fa387cf44672c56", - "reference": "1690ee1728827f9deb4b60457fa387cf44672c56", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/18ced90d4b882ed449b2278fea8692f8f7d1c13c", + "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c", "shasum": "" }, "require": { @@ -10417,7 +10342,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.8-dev" } }, "autoload": { @@ -10452,7 +10377,7 @@ ], "support": { "issues": "https://github.com/php-debugbar/php-debugbar/issues", - "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.7.6" + "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.8.0" }, "funding": [ { @@ -10464,7 +10389,7 @@ "type": "github" } ], - "time": "2026-04-30T07:31:44+00:00" + "time": "2026-07-02T12:38:20+00:00" }, { "name": "php-debugbar/symfony-bridge", @@ -10728,33 +10653,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.7", + "version": "14.2.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "186dab580576598076de6818596d12b61801880e" + "reference": "82f6e49ff224e2cde923d74425e583a883910783" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", - "reference": "186dab580576598076de6818596d12b61801880e", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783", + "reference": "82f6e49ff224e2cde923d74425e583a883910783", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", + "ext-mbstring": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", - "php": ">=8.3", - "phpunit/php-text-template": "^5.0", - "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.1.2", - "sebastian/lines-of-code": "^4.0.1", - "sebastian/version": "^6.0", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.28" + "phpunit/phpunit": "^13.2.2" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10763,7 +10690,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "14.2.x-dev" } }, "autoload": { @@ -10792,7 +10719,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3" }, "funding": [ { @@ -10812,32 +10739,32 @@ "type": "tidelift" } ], - "time": "2026-06-01T13:24:19+00:00" + "time": "2026-07-06T15:04:02+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10865,7 +10792,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, "funding": [ { @@ -10885,28 +10812,28 @@ "type": "tidelift" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2026-02-06T04:33:26+00:00" }, { "name": "phpunit/php-invoker", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-pcntl": "*" @@ -10914,7 +10841,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10941,40 +10868,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:58+00:00" + "time": "2026-02-06T04:34:47+00:00" }, { "name": "phpunit/php-text-template", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11001,40 +10940,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:16+00:00" + "time": "2026-02-06T04:36:37+00:00" }, { "name": "phpunit/php-timer", - "version": "8.0.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -11061,56 +11012,70 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:38+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { "name": "phpunit/phpunit", - "version": "12.5.30", + "version": "13.2.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" + "reference": "2beef9f448c9e04914a273c89ff6d039f8b97bc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", - "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2beef9f448c9e04914a273c89ff6d039f8b97bc8", + "reference": "2beef9f448c9e04914a273c89ff6d039f8b97bc8", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.7", - "phpunit/php-file-iterator": "^6.0.1", - "phpunit/php-invoker": "^6.0.0", - "phpunit/php-text-template": "^5.0.0", - "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.1", - "sebastian/comparator": "^7.1.8", - "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.2", - "sebastian/exporter": "^7.0.3", - "sebastian/global-state": "^8.0.3", - "sebastian/object-enumerator": "^7.0.0", - "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.4", - "sebastian/version": "^6.0.0", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.2.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -11119,7 +11084,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "13.2-dev" } }, "autoload": { @@ -11151,7 +11116,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.5" }, "funding": [ { @@ -11159,32 +11124,32 @@ "type": "other" } ], - "time": "2026-06-15T13:12:30+00:00" + "time": "2026-07-25T06:59:14+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", - "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.2-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -11208,7 +11173,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" }, "funding": [ { @@ -11228,31 +11193,31 @@ "type": "tidelift" } ], - "time": "2026-05-17T05:29:34+00:00" + "time": "2026-02-06T04:39:44+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.8", + "version": "8.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "7c65c1e79836812819705b473a90c12399542485" + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", - "reference": "7c65c1e79836812819705b473a90c12399542485", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0.3" + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.2" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -11260,7 +11225,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.1-dev" + "dev-main": "8.3-dev" } }, "autoload": { @@ -11300,7 +11265,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" }, "funding": [ { @@ -11320,33 +11285,33 @@ "type": "tidelift" } ], - "time": "2026-05-21T04:45:25+00:00" + "time": "2026-06-05T03:06:45+00:00" }, { "name": "sebastian/complexity", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11370,41 +11335,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:25+00:00" + "time": "2026-02-06T04:41:32+00:00" }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -11437,35 +11414,47 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-06-05T03:04:51+00:00" }, { "name": "sebastian/environment", - "version": "8.1.2", + "version": "9.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", - "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.26" + "phpunit/phpunit": "^13.1.11" }, "suggest": { "ext-posix": "*" @@ -11473,7 +11462,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -11501,7 +11490,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" }, "funding": [ { @@ -11521,34 +11510,34 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:40:20+00:00" + "time": "2026-05-25T13:41:38+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.3", + "version": "8.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", - "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/recursion-context": "^7.0.1" + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -11591,7 +11580,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.1" }, "funding": [ { @@ -11611,35 +11600,173 @@ "type": "tidelift" } ], - "time": "2026-05-20T04:37:17+00:00" + "time": "2026-07-13T11:35:11+00:00" }, { - "name": "sebastian/global-state", - "version": "8.0.3", + "name": "sebastian/file-filter", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", - "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0.1" + "php": ">=8.4" }, "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^12.5.28" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" } }, "autoload": { @@ -11665,7 +11792,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" }, "funding": [ { @@ -11685,33 +11812,33 @@ "type": "tidelift" } ], - "time": "2026-06-01T15:10:33+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", - "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "shasum": "" }, "require": { - "nikic/php-parser": "^5.7.0", - "php": ">=8.3" + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -11735,7 +11862,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" }, "funding": [ { @@ -11755,34 +11882,34 @@ "type": "tidelift" } ], - "time": "2026-05-19T16:22:07+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { "name": "sebastian/object-enumerator", - "version": "7.0.0", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -11805,40 +11932,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:48+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { "name": "sebastian/object-reflector", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11861,40 +12000,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:17+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { "name": "sebastian/recursion-context", - "version": "7.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -11925,7 +12076,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" }, "funding": [ { @@ -11945,32 +12096,32 @@ "type": "tidelift" } ], - "time": "2025-08-13T04:44:59+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { "name": "sebastian/type", - "version": "6.0.4", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "82ff822c2edc46724be9f7411d3163021f602773" + "reference": "fee0309275847fefd7636167085e379c1dbf6990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", - "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11994,7 +12145,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" }, "funding": [ { @@ -12014,29 +12165,29 @@ "type": "tidelift" } ], - "time": "2026-05-20T06:45:45+00:00" + "time": "2026-05-20T06:49:11+00:00" }, { "name": "sebastian/version", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -12060,15 +12211,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" } ], - "time": "2025-02-07T05:00:38+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "spatie/laravel-ray", diff --git a/config/flare.php b/config/flare.php index 9edd19f5..0b8c6187 100644 --- a/config/flare.php +++ b/config/flare.php @@ -1,8 +1,6 @@ env('FLARE_KEY'), - /* - | - |-------------------------------------------------------------------------- - | Flare Base URL - |-------------------------------------------------------------------------- - | - | Which server should be used to send the reports/traces to. - | - */ - 'base_url' => env('FLARE_BASE_URL', Api::BASE_URL), - /* |-------------------------------------------------------------------------- | Collects @@ -47,22 +34,6 @@ return [ extra: [] ), - /* - |-------------------------------------------------------------------------- - | Attribute providers - |-------------------------------------------------------------------------- - | - | When sending an error report or trace to Flare attributes can be added to - | the report or trace for common entries. An example of such an entry is - | the currently authenticated user. In an attribute provider you can - | specify which attributes should be sent. - | - */ - - 'attribute_providers' => [ - 'user' => LaravelUserAttributesProvider::class, - ], - /* |-------------------------------------------------------------------------- | Censor data @@ -88,19 +59,49 @@ return [ 'X-XSRF-TOKEN', ], 'client_ips' => false, + 'cookies' => false, + 'session' => false, ], /* |-------------------------------------------------------------------------- - | Reporting log statements + | Sender |-------------------------------------------------------------------------- | - | If this setting is `false` log statements won't be sent as events to Flare, - | no matter which error level you specified in the Flare log channel. + | The sender is responsible for sending the error reports and traces to + | Flare. By default, Laravel Flare sends them over HTTP. To use the local + | Flare daemon, switch the sender class to + | `Spatie\FlareClient\Senders\DaemonSender::class` and set `daemon_url`. + | The daemon sender defaults to localhost on port 8787 and uses its own + | default timeouts and fallback sender config unless you override them. | */ - 'send_logs_as_events' => true, + 'sender' => [ + 'class' => LaravelHttpSender::class, + 'config' => [ + 'timeout' => 10, + ], + ], + + // Daemon sender example + // 'sender' => [ + // 'class' => \Spatie\FlareClient\Senders\DaemonSender::class, + // 'config' => [ + // 'daemon_url' => env('FLARE_DAEMON_URL', 'http://127.0.0.1:8787'), + // ], + // ], + + /* + |-------------------------------------------------------------------------- + | Report + |-------------------------------------------------------------------------- + | + | Flare reports errors and exceptions happening within your application. + | + */ + + 'report' => env('FLARE_REPORT', true), /* |-------------------------------------------------------------------------- @@ -109,10 +110,27 @@ return [ | When reporting errors, you can specify which error levels should be | reported. By default, all error levels are reported by setting | this value to `null`. - */ + */ 'report_error_levels' => null, + /* + |-------------------------------------------------------------------------- + | Override grouping + |-------------------------------------------------------------------------- + | + | Flare will try to group errors and exceptions as best as possible, that + | being said, sometimes you might want to override the grouping. You can + | do this by adding exception classes to this array which should always + | be grouped by exception class, exception message or exception class + | and message. + | + */ + + 'overridden_groupings' => [ + // Illuminate\Http\Client\ConnectionException::class => Spatie\FlareClient\Enums\OverriddenGrouping::ExceptionMessageAndClass, + ], + /* |-------------------------------------------------------------------------- | Share button @@ -126,40 +144,6 @@ return [ 'enable_share_button' => true, - /* - |-------------------------------------------------------------------------- - | Override grouping - |-------------------------------------------------------------------------- - | - | Flare will try to group errors and exceptions as best as possible, that - | being said, sometimes you might want to override the grouping. You can - | do this by adding exception classes to this array which should always - | be grouped by exception class, exception message or exception class - | and message. - | - */ - - 'overridden_groupings' => [ - // Illuminate\Http\Client\ConnectionException::class => Spatie\FlareClient\Enums\OverriddenGrouping::ExceptionMessageAndClass, - ], - - /* - |-------------------------------------------------------------------------- - | Sender - |-------------------------------------------------------------------------- - | - | The sender is responsible for sending the error reports and traces to - | Flare it can be configured if needed. - | - */ - - 'sender' => [ - 'class' => LaravelHttpSender::class, - 'config' => [ - 'timeout' => 10, - ], - ], - /* |-------------------------------------------------------------------------- | Trace @@ -170,7 +154,7 @@ return [ | */ - 'trace' => env('FLARE_TRACE', false), + 'trace' => env('FLARE_TRACE', true), /* |-------------------------------------------------------------------------- @@ -183,26 +167,35 @@ return [ | which means that 10% of the traces will be recorded. | */ + 'sampler' => [ 'class' => RateSampler::class, 'config' => [ - 'rate' => 0.1, + 'rate' => env('FLARE_SAMPLER_RATE', 0.1), ], ], /* |-------------------------------------------------------------------------- - | Trace limits + | Log |-------------------------------------------------------------------------- | - | Limits for the tracing data. These limits are used to prevent - | the tracing data from growing too large. + | Logging show you an overview of log entries within your application. | */ - 'trace_limits' => [ - 'max_spans' => 512, - 'max_attributes_per_span' => 128, - 'max_span_events_per_span' => 128, - 'max_attributes_per_span_event' => 128, - ], + + 'log' => env('FLARE_LOG', false), + + /* + |-------------------------------------------------------------------------- + | Minimal log level + |-------------------------------------------------------------------------- + | + | You can specify the minimal (Monolog) log level that should be sent to Flare. + | Log levels lower than the specified level will be ignored. + | If null all log levels will be sent to Flare. + | + */ + + 'minimal_log_level' => null, ]; diff --git a/config/image.php b/config/image.php deleted file mode 100644 index b984a0f0..00000000 --- a/config/image.php +++ /dev/null @@ -1,22 +0,0 @@ - Driver::class, - -]; diff --git a/config/services.php b/config/services.php index b784e544..7a5cdfa5 100644 --- a/config/services.php +++ b/config/services.php @@ -39,4 +39,8 @@ return [ 'token' => env('CLOUDCONVERT_API_TOKEN'), ], + 'brrr' => [ + 'webhook_url' => env('BRRR_WEBHOOK_URL'), + ], + ]; diff --git a/database/factories/AboutFactory.php b/database/factories/AboutFactory.php new file mode 100644 index 00000000..5af2532e --- /dev/null +++ b/database/factories/AboutFactory.php @@ -0,0 +1,24 @@ + + */ +class AboutFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'content' => $this->faker->paragraph, + ]; + } +} diff --git a/database/factories/SettingFactory.php b/database/factories/SettingFactory.php new file mode 100644 index 00000000..8df15756 --- /dev/null +++ b/database/factories/SettingFactory.php @@ -0,0 +1,24 @@ + + */ +class SettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'winter_effect_enabled' => false, + ]; + } +} diff --git a/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php new file mode 100644 index 00000000..e336912f --- /dev/null +++ b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('token_hash', 64)->unique(); + $table->string('client_id'); + $table->string('me'); + $table->string('scope'); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index('client_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('micropub_tokens'); + } +}; diff --git a/database/migrations/2026_08_23_101358_create_settings_table.php b/database/migrations/2026_08_23_101358_create_settings_table.php new file mode 100644 index 00000000..a4ea86d2 --- /dev/null +++ b/database/migrations/2026_08_23_101358_create_settings_table.php @@ -0,0 +1,28 @@ +id(); + $table->boolean('winter_effect_enabled')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/2026_08_28_142830_create_about_table.php b/database/migrations/2026_08_28_142830_create_about_table.php new file mode 100644 index 00000000..15ab6f84 --- /dev/null +++ b/database/migrations/2026_08_28_142830_create_about_table.php @@ -0,0 +1,28 @@ +id(); + $table->text('content'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('about'); + } +}; diff --git a/package-lock.json b/package-lock.json index 55d97494..05c9ad3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,9 +121,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -168,9 +168,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -237,9 +237,9 @@ } }, "node_modules/@csstools/selector-resolve-nested": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", - "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz", + "integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==", "dev": true, "funding": [ { @@ -725,9 +725,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -782,9 +782,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1019,9 +1019,9 @@ "license": "MIT" }, "node_modules/@typescript-eslint/types": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", - "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -1135,16 +1135,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -1418,9 +1418,9 @@ } }, "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -1430,7 +1430,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -1454,7 +1454,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1655,9 +1655,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -1749,9 +1749,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC" }, @@ -1836,9 +1836,9 @@ } }, "node_modules/globby": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", - "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz", + "integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==", "dev": true, "license": "MIT", "dependencies": { @@ -1857,9 +1857,9 @@ } }, "node_modules/globby/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -2137,9 +2137,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2153,23 +2153,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -2188,9 +2188,9 @@ } }, "node_modules/lightningcss-cli": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli/-/lightningcss-cli-1.32.0.tgz", - "integrity": "sha512-IFb/ChmSEbeWU3xeRybR6WFlJXCvfDS84//PUzLrRACgvoWrwRJBmcPS9azSo7LMh5QqEuyKanPBByhKM5z01Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli/-/lightningcss-cli-1.33.0.tgz", + "integrity": "sha512-/tBcBZlBFFxy1iYKDC/HSH/NEjv7Frq6dmDVKRbhGJQI/gFJIJ1/4ZXclxJkd0eEjBjDc5MY0YugHcvJVVGJVg==", "dev": true, "hasInstallScript": true, "license": "MPL-2.0", @@ -2208,23 +2208,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-cli-android-arm64": "1.32.0", - "lightningcss-cli-darwin-arm64": "1.32.0", - "lightningcss-cli-darwin-x64": "1.32.0", - "lightningcss-cli-freebsd-x64": "1.32.0", - "lightningcss-cli-linux-arm-gnueabihf": "1.32.0", - "lightningcss-cli-linux-arm64-gnu": "1.32.0", - "lightningcss-cli-linux-arm64-musl": "1.32.0", - "lightningcss-cli-linux-x64-gnu": "1.32.0", - "lightningcss-cli-linux-x64-musl": "1.32.0", - "lightningcss-cli-win32-arm64-msvc": "1.32.0", - "lightningcss-cli-win32-x64-msvc": "1.32.0" + "lightningcss-cli-android-arm64": "1.33.0", + "lightningcss-cli-darwin-arm64": "1.33.0", + "lightningcss-cli-darwin-x64": "1.33.0", + "lightningcss-cli-freebsd-x64": "1.33.0", + "lightningcss-cli-linux-arm-gnueabihf": "1.33.0", + "lightningcss-cli-linux-arm64-gnu": "1.33.0", + "lightningcss-cli-linux-arm64-musl": "1.33.0", + "lightningcss-cli-linux-x64-gnu": "1.33.0", + "lightningcss-cli-linux-x64-musl": "1.33.0", + "lightningcss-cli-win32-arm64-msvc": "1.33.0", + "lightningcss-cli-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-cli-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-android-arm64/-/lightningcss-cli-android-arm64-1.32.0.tgz", - "integrity": "sha512-4O3QY+VdgpBZLIq4crcKOEPVAXX0p7zDoykuTVstRtyolg9XU8CntdtbxcMPSC4SkzAuM/W4KsnPAzmv6Jb5LA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-android-arm64/-/lightningcss-cli-android-arm64-1.33.0.tgz", + "integrity": "sha512-c0Xd7Gxaw3mNOyrb9ET1JH3JjuB69GY/6pHO4vgwEbIoHCXAt6DBg6kxr5t/oZL9LRxdnZCXF+o0Evz6kuR/xA==", "cpu": [ "arm64" ], @@ -2243,9 +2243,9 @@ } }, "node_modules/lightningcss-cli-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-arm64/-/lightningcss-cli-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-Xx+zeD7bDKJZwbd1N63TJfIUHEtYspf+tqObdnQEJEvZAwmGfA4iEGrkCRT8R57tDRBDSXg3XHMDDvo/cq7gBQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-arm64/-/lightningcss-cli-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-sso5hSFPis7ldw2FcBopsZviSVAXWRGX8ybUwMhQHssTlERenQJ88WihZs08tCdSnQULo2kunnrGhh4VjRw8Fg==", "cpu": [ "arm64" ], @@ -2264,9 +2264,9 @@ } }, "node_modules/lightningcss-cli-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-x64/-/lightningcss-cli-darwin-x64-1.32.0.tgz", - "integrity": "sha512-fYWANZ8RJDpI0tBcPQ7oBOYihfXmgDBHR4lZ6d4z7rcRLlZAOeI00mTO0IXAKfSm/UgnatjM4aBuNlLh8L9noA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-x64/-/lightningcss-cli-darwin-x64-1.33.0.tgz", + "integrity": "sha512-/aDYpMv2QKpJoSIwvwpIs4tIX9SCU894eospSm55FR5MxDZkJDy4fEX6elsXkHCBDqbAknb4qd2h/oDMLTC4Ew==", "cpu": [ "x64" ], @@ -2285,9 +2285,9 @@ } }, "node_modules/lightningcss-cli-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-freebsd-x64/-/lightningcss-cli-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-TJm7z1Ghvo9FKAza2KvfkFgH/9rcV1xAhCYQZlLrV0CiuTZ17uzLobBWb9oelGWUG+wTnEs6XEl4h/ve61YySg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-freebsd-x64/-/lightningcss-cli-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-kK9u4IEAvt3+1m7lkloyfZNfXykXq2vCH7lgZ5aEqa8VuBZYqvcAIQ4Qt1593QJxbko9HEKUP0erdSxw7k7eFw==", "cpu": [ "x64" ], @@ -2306,9 +2306,9 @@ } }, "node_modules/lightningcss-cli-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm-gnueabihf/-/lightningcss-cli-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-/jS9L5p3eexs5QJvARiDGxibz1umKJmmtff86fWXl3r7RbEUFrMAD4q1WhAR7DurRFgc1YWld6r0mX1YHEYttA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm-gnueabihf/-/lightningcss-cli-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-4bHmUlYCb8WKdPKx2yhk8y/kD034JVuUatDMWav0uU/hEIYJrxPimWBJJ4w2lgpcWchVnk/Kuihs4vaLjO/ozw==", "cpu": [ "arm" ], @@ -2327,9 +2327,9 @@ } }, "node_modules/lightningcss-cli-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-gnu/-/lightningcss-cli-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-PSdjwcRtSrJpsaqnY3ebnCfDOJ5ePi8s/0ItL3CX1b1Eu0iy44xo7MjgES1ZOQ2ntOthPRqaGsbAiVBLbgFLYQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-gnu/-/lightningcss-cli-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-DkYmkBix3icAZKZsxGsjBAj59CIV0zflZtsT66lCPd2z3OqBLJlhKRrw4TlAW7mIkYeOEYWKVz/G00ZGXNse4A==", "cpu": [ "arm64" ], @@ -2351,9 +2351,9 @@ } }, "node_modules/lightningcss-cli-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-musl/-/lightningcss-cli-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-t6DdpXFtEdonZHzRgH8cmqhC2o4tl0KrD33cDBBniJz5TmWS20MJxb4YEr4WqBLksqW8GE399HJo+g8/YVuKcA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-musl/-/lightningcss-cli-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-euNOhzo1ysRl719HfFzRe9r9usWO2Z/Nb8RnmqXxb/kvzOgAv2BcflpoTu6tWhSrbHX6YS/WP6mMFrmII6T7Hw==", "cpu": [ "arm64" ], @@ -2375,9 +2375,9 @@ } }, "node_modules/lightningcss-cli-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-gnu/-/lightningcss-cli-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-QMQllbHYkbkQ4N+v8OGExQlGHBc3YZIcKlVkYucQQj66thkFQsRjmv8p5q3iCB0inNsCoSZ8lBspugkU1iPlPg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-gnu/-/lightningcss-cli-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-9Q4DAglm17bLhA+HZPB+vxjrHXdhR/8U44zyc8MQNA5Rw+tisvcoR/lv4iuBUFn2qreYVRWQq0wP0rgdhTSxyQ==", "cpu": [ "x64" ], @@ -2399,9 +2399,9 @@ } }, "node_modules/lightningcss-cli-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-musl/-/lightningcss-cli-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-dMSWdk4kMAi5f+J1xetxRCDQOvPix2whT0UdjuwP8r/5Xcdl2SQU/c80MQzu1S82kVDEANs1vHVEHp/+26LUnA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-musl/-/lightningcss-cli-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-+EobHdqxQ21SUMF8OU0qcizNIrLpPDaPTKQCaGbra9gL3NrW3ELG+exZl5y+WzJsEjLZ0hhjbCFjHUL/03H2JA==", "cpu": [ "x64" ], @@ -2423,9 +2423,9 @@ } }, "node_modules/lightningcss-cli-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-win32-arm64-msvc/-/lightningcss-cli-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-MJo22OqSp9FLV10nTRDJQhP6zkEBFqBFQe9mnjfCs+G1Ft+QimIPmC+gBqZHFXveOBOsjFLzU72q0FPvRWFmZQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-win32-arm64-msvc/-/lightningcss-cli-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-iwQ+8rHQy3ewC4c43Ik3UZ8TCC/rjShP3r/8TOPw4fT8wQmW7dS3BNNHPK+LWz9qNrObATJ4MZEEZVRneckOBw==", "cpu": [ "arm64" ], @@ -2444,9 +2444,9 @@ } }, "node_modules/lightningcss-cli-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-cli-win32-x64-msvc/-/lightningcss-cli-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-nOBHLPeePXZpGR4Ptp+gkOIkr9pxlq2dFmO954ol5zBi/iOKxuD1hUTIQ7aG+ldKIx4eH9jwoTfZ6owUkm1paA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-cli-win32-x64-msvc/-/lightningcss-cli-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-sgtNPw2gxnY8OzCCMTKCkHSIiBYb/wfXLpLMmDIJFzcX2OETY+VOnQd7OBHx+hVSlrMU7iWL6oJ5rc+nQwteVA==", "cpu": [ "x64" ], @@ -2465,9 +2465,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -2486,9 +2486,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -2507,9 +2507,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -2528,9 +2528,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -2549,9 +2549,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -2573,9 +2573,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -2597,9 +2597,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -2621,9 +2621,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -2645,9 +2645,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -2666,9 +2666,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2808,9 +2808,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2953,9 +2953,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2966,9 +2966,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2986,7 +2986,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3236,9 +3236,9 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { @@ -3269,9 +3269,9 @@ } }, "node_modules/stylelint": { - "version": "17.14.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz", - "integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==", + "version": "17.14.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", + "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", "dev": true, "funding": [ { @@ -3287,7 +3287,7 @@ "dependencies": { "@csstools/css-calc": "^3.2.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.1.5", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", @@ -3299,9 +3299,9 @@ "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.3", + "file-entry-cache": "^11.1.5", "global-modules": "^2.0.0", - "globby": "^16.2.0", + "globby": "^16.2.1", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", @@ -3311,12 +3311,12 @@ "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.15", + "postcss": "^8.5.16", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.4", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", - "supports-hyperlinks": "^4.4.0", + "supports-hyperlinks": "^4.5.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" @@ -3400,9 +3400,9 @@ } }, "node_modules/stylelint/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 4e2ebd32..e255209f 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,2 +1,2 @@ -@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}} +@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@media (hover:hover){&:hover{background-color:color-mix(in oklch, var(--clr-border) 70%, transparent)}&[aria-pressed=true]:hover{background-color:var(--clr-text)}}&[aria-pressed=true]{background-color:var(--clr-text);color:var(--clr-background)}}}.sr-only{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}.token-reveal{border:1px solid var(--clr-border);background:light-dark(oklch(96% .08 145),oklch(28% .08 145));border-radius:16px;margin-block-end:1em;padding:1em 1.2em;& input{border:1px solid var(--clr-border);border-radius:8px;width:100%;padding:.5em .7em;font-family:monospace}}.scope-checkboxes{flex-wrap:wrap;align-items:center;gap:1em;display:flex}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index eeec79a3..200e0d25 100644 Binary files a/public/assets/css/app.css.br and b/public/assets/css/app.css.br differ diff --git a/public/assets/css/app.css.map b/public/assets/css/app.css.map index b817d58d..6413bbe9 100644 --- a/public/assets/css/app.css.map +++ b/public/assets/css/app.css.map @@ -1 +1 @@ -{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,uBAIA,iDAIE,yEAME,uBAON,oDHvCF,kBG+CE,SACE,oBChDF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CCrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MC/BA,sFAME,+CCNF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA","sources":["resources/css/app.css","resources/css/layout.css","resources/css/colours.css","resources/css/reset.css","resources/css/theme-selector.css","resources/css/header.css","resources/css/pagination.css","resources/css/notes.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 2/3;\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n #theme-selector {\n button {\n appearance: none;\n border: none;\n background: none;\n\n &:hover {\n cursor: pointer;\n }\n }\n\n /* This is the element with the [popover] attribute */\n #theme-selector-dropdown {\n position: absolute;\n position-area: block-end span-inline-start;\n margin: 0;\n border: 2px solid var(--clr-border);\n background-color: var(--clr-background);\n color: var(--clr-text);\n\n fieldset {\n border: 0;\n display: flex;\n flex-direction: row;\n gap: 1ch;\n\n input {\n display: none;\n }\n }\n }\n\n /*\n * This is the element with the [popover] attribute\n * Here we are styling the open and closing animations\n */\n @media (prefers-reduced-motion: no-preference) {\n #theme-selector-dropdown {\n &:popover-open {\n transform: translateY(0) scale(1);\n opacity: 1;\n\n /*\n * Start styles for the opening transition.\n * Added to :popover-open, but after the opened styles.\n */\n @starting-style {\n transform: translateY(30px) scale(0);\n opacity: 0;\n }\n }\n\n /*\n * End styles for the closing transition.\n */\n transform: translateY(0) scale(0);\n opacity: 0;\n\n /*\n * Enumerate transitioning properties, including display and overlay.\n */\n transition: transform, opacity, display allow-discrete, overlay allow-discrete;\n transition-duration: 0.5s;\n transform-origin: top right;\n }\n }\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]} \ No newline at end of file +{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDHjDF,kBGyDE,SACE,oBC1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CIrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MH/BA,sFAME,+CENF,0LASE,gLAWE,uBAIA,qEAKA,mDAMA,qBACE,iFAIA,6DAKF,oFAOJ,uIAYA,iEAIA,kGAKA,4DAIA,qDAIA,mIDjFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA,0FAIA,wKAUE,iHASF","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/header.css","resources/css/reset.css","resources/css/pagination.css","resources/css/admin-tokens.css","resources/css/theme-selector.css","resources/css/notes.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n\n .token-reveal {\n margin-block-end: 1em;\n padding: 1em 1.2em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n background: light-dark(\n oklch(96% 0.08 145deg),\n oklch(28% 0.08 145deg)\n );\n\n input {\n width: 100%;\n font-family: monospace;\n padding: 0.5em 0.7em;\n border-radius: 8px;\n border: 1px solid var(--clr-border);\n }\n }\n\n .scope-checkboxes {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 1em;\n }\n}\n","@layer components {\n #theme-selector {\n display: flex;\n justify-self: start;\n align-items: center;\n gap: .25rem;\n padding: .25rem;\n border-radius: 999px;\n background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);\n\n button {\n display: grid;\n place-items: center;\n appearance: none;\n border: none;\n border-radius: 999px;\n padding: .375rem;\n background: transparent;\n color: inherit;\n transition: background-color .2s, color .2s;\n\n &:hover {\n cursor: pointer;\n }\n\n &:focus-visible {\n outline: 2px solid var(--clr-text);\n outline-offset: 2px;\n }\n\n svg {\n width: 1.5rem;\n height: 1.5rem;\n fill: currentcolor;\n }\n\n @media (hover: hover) {\n &:hover {\n background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);\n }\n\n &[aria-pressed=\"true\"]:hover {\n background-color: var(--clr-text);\n }\n }\n\n &[aria-pressed=\"true\"] {\n background-color: var(--clr-text);\n color: var(--clr-background);\n }\n }\n }\n\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index d258af49..2fda6303 100644 Binary files a/public/assets/css/app.css.zst and b/public/assets/css/app.css.zst differ diff --git a/public/assets/js/app.js b/public/assets/js/app.js index d19f257c..297c3aa1 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1,2 +1,2 @@ -(()=>{var l=class{constructor(){}async register(){let r=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(r.challenge),rp:{id:r.rp.id,name:r.rp.name},user:{id:new TextEncoder().encode(window.atob(r.user.id)),name:r.user.name,displayName:r.user.displayName},pubKeyCredParams:r.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:r.authenticatorSelection,timeout:6e4},t=await navigator.credentials.create({publicKey:e});if(!t)throw new Error("Error generating a passkey");let n={id:t.id?t.id:null,type:t.type?t.type:null,rawId:t.rawId?this.bufferToBase64URLString(t.rawId):null,response:{attestationObject:t.response.attestationObject?this.bufferToBase64URLString(t.response.attestationObject):null,clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let r=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(r.challenge),userVerification:r.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let t={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(t),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(r){let e=r.replace(/-/g,"+").replace(/_/g,"/"),t=(4-e.length%4)%4,n=e.padEnd(e.length+t,"="),a=window.atob(n),o=new ArrayBuffer(a.length),i=new Uint8Array(o);for(let s=0;sr.addEventListener("input",e=>{let t=e.target.value;this.widget.querySelectorAll(".toggle svg").forEach(u=>{u.classList.contains(t)?u.style.display="":u.style.display="none"});let n;switch(t){case"dark":case"light":n=t;break;default:n="light dark"}let a=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",o=document.querySelector("html"),i=o.style.getPropertyValue("color-scheme");i===""&&(i="light dark");let s=!1;n!==i&&(n==="light dark"?s=i!==a:i==="light dark"?s=n!==a:s=!0),o.style.viewTransitionName="changing-theme",s&&document.startViewTransition?document.startViewTransition(()=>{document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n)}):(document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n))}))}};var h=new l;document.querySelectorAll(".add-passkey").forEach(c=>{c.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(c=>{c.addEventListener("click",()=>{h.login()})});var p=new d;p.setupEventListeners();})(); +(()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})(); //# sourceMappingURL=app.js.map diff --git a/public/assets/js/app.js.br b/public/assets/js/app.js.br index 40b0dd0d..7567f865 100644 Binary files a/public/assets/js/app.js.br and b/public/assets/js/app.js.br differ diff --git a/public/assets/js/app.js.map b/public/assets/js/app.js.map index 79c78c67..a2fb0730 100644 --- a/public/assets/js/app.js.map +++ b/public/assets/js/app.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../../../resources/js/auth.js", "../../../resources/js/theme-selector.js", "../../../resources/js/app.js"], - "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n }\n\n setupEventListeners() {\n this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => {\n let theme = e.target.value;\n\n // Update current icon\n this.widget.querySelectorAll('.toggle svg').forEach((svg) => {\n if (svg.classList.contains(theme)) {\n svg.style.display = '';\n } else {\n svg.style.display = 'none';\n }\n });\n\n // Set the theme\n let selectedTheme;\n switch (theme) {\n case 'dark':\n case 'light':\n selectedTheme = theme;\n break;\n default:\n selectedTheme = 'light dark';\n }\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.querySelector('html');\n let currentTheme = html.style.getPropertyValue('color-scheme');\n if (currentTheme === '') {\n currentTheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentTheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentTheme !== systemTheme;\n } else if (currentTheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(() => {\n // Close the popover\n document.querySelector('#theme-selector-dropdown').togglePopover();\n\n // Set the colour theme\n html.style.setProperty('color-scheme', selectedTheme);\n });\n } else {\n // Close the popover\n document.querySelector('#theme-selector-dropdown').togglePopover();\n\n // Set the colour theme\n html.style.setProperty('color-scheme', selectedTheme);\n }\n }));\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], - "mappings": "MAAA,IAAMA,EAAN,KAAW,CACT,aAAc,CAAC,CAEf,MAAM,UAAW,CACf,IAAMC,EAAgB,MAAM,KAAK,iBAAiB,EAE5CC,EAAqC,CACzC,UAAW,KAAK,wBAAwBD,EAAc,SAAS,EAC/D,GAAI,CACF,GAAIA,EAAc,GAAG,GACrB,KAAMA,EAAc,GAAG,IACzB,EACA,KAAM,CACJ,GAAI,IAAI,YAAY,EAAE,OAAO,OAAO,KAAKA,EAAc,KAAK,EAAE,CAAC,EAC/D,KAAMA,EAAc,KAAK,KACzB,YAAaA,EAAc,KAAK,WAClC,EACA,iBAAkBA,EAAc,iBAChC,mBAAoB,CAAC,EACrB,uBAAwBA,EAAc,uBACtC,QAAS,GACX,EAEME,EAAa,MAAM,UAAU,YAAY,OAAO,CACpD,UAAWD,CACb,CAAC,EACD,GAAI,CAACC,EACH,MAAM,IAAI,MAAM,4BAA4B,EAG9C,IAAMC,EAAmC,CACvC,GAAID,EAAW,GAAKA,EAAW,GAAK,KACpC,KAAMA,EAAW,KAAOA,EAAW,KAAO,KAC1C,MAAOA,EAAW,MAAQ,KAAK,wBAAwBA,EAAW,KAAK,EAAI,KAC3E,SAAU,CACR,kBAAmBA,EAAW,SAAS,kBAAoB,KAAK,wBAAwBA,EAAW,SAAS,iBAAiB,EAAI,KACjI,eAAgBA,EAAW,SAAS,eAAiB,KAAK,wBAAwBA,EAAW,SAAS,cAAc,EAAI,IAC1H,CACF,EAYA,GAAI,EAVuB,MAAM,OAAO,MAAM,2BAA4B,CACxE,OAAQ,OACR,KAAM,KAAK,UAAUC,CAAgC,EACrD,MAAO,WACP,QAAS,CACP,eAAgB,mBAChB,eAAgB,SAAS,cAAc,yBAAyB,EAAE,aAAa,SAAS,CAC1F,CACF,CAAC,GAEuB,GACtB,MAAM,IAAI,MAAM,0BAA0B,EAG5C,OAAO,SAAS,OAAO,CACzB,CAEA,MAAM,kBAAmB,CAKvB,OAAO,MAJU,MAAM,MAAM,2BAA4B,CACvD,OAAQ,KACV,CAAC,GAEqB,KAAK,CAC7B,CAEA,MAAM,OAAQ,CACZ,IAAMC,EAAY,MAAM,KAAK,aAAa,EAEpCC,EAAsB,MAAM,UAAU,YAAY,IAAI,CAC1D,UAAW,CACT,UAAW,KAAK,wBAAwBD,EAAU,SAAS,EAC3D,iBAAkBA,EAAU,iBAC5B,QAAS,GACX,CACF,CAAC,EAED,GAAI,CAACC,EACH,MAAM,IAAI,MAAM,uBAAuB,EAGzC,IAAMF,EAAmC,CACvC,GAAIE,EAAoB,GAAKA,EAAoB,GAAK,GACtD,KAAMA,EAAoB,KAAOA,EAAoB,KAAO,GAC5D,MAAOA,EAAoB,MAAQ,KAAK,wBAAwBA,EAAoB,KAAK,EAAI,GAC7F,SAAU,CACR,kBAAmBA,EAAoB,SAAS,kBAAoB,KAAK,wBAAwBA,EAAoB,SAAS,iBAAiB,EAAI,GACnJ,eAAgBA,EAAoB,SAAS,eAAiB,KAAK,wBAAwBA,EAAoB,SAAS,cAAc,EAAI,GAC1I,UAAWA,EAAoB,SAAS,UAAY,KAAK,wBAAwBA,EAAoB,SAAS,SAAS,EAAI,GAC3H,WAAYA,EAAoB,SAAS,WAAa,KAAK,wBAAwBA,EAAoB,SAAS,UAAU,EAAI,EAChI,CACF,EAWA,GAAI,EATiB,MAAM,OAAO,MAAM,iBAAkB,CACxD,OAAQ,OACR,KAAM,KAAK,UAAUF,CAAgC,EACrD,QAAS,CACP,eAAgB,mBAChB,eAAgB,SAAS,cAAc,yBAAyB,EAAE,aAAa,SAAS,CAC1F,CACF,CAAC,GAEiB,GAChB,MAAM,IAAI,MAAM,cAAc,EAGhC,OAAO,SAAS,OAAO,QAAQ,CACjC,CAEA,MAAM,cAAe,CAKnB,OAAO,MAJU,MAAM,MAAM,iBAAkB,CAC7C,OAAQ,KACV,CAAC,GAEqB,KAAK,CAC7B,CAUA,wBAAwBG,EAAiB,CAEvC,IAAMC,EAASD,EAAgB,QAAQ,KAAM,GAAG,EAAE,QAAQ,KAAM,GAAG,EAQ7DE,GAAa,EAAKD,EAAO,OAAS,GAAM,EACxCE,EAASF,EAAO,OAAOA,EAAO,OAASC,EAAW,GAAG,EAErDE,EAAS,OAAO,KAAKD,CAAM,EAE3BE,EAAS,IAAI,YAAYD,EAAO,MAAM,EACtCE,EAAQ,IAAI,WAAWD,CAAM,EACnC,QAASE,EAAI,EAAGA,EAAIH,EAAO,OAAQG,IACjCD,EAAMC,CAAC,EAAIH,EAAO,WAAWG,CAAC,EAEhC,OAAOF,CACT,CAUA,wBAAwBA,EAAQ,CAC9B,IAAMC,EAAQ,IAAI,WAAWD,CAAM,EAC/BG,EAAM,GACV,QAAWC,KAAYH,EACrBE,GAAO,OAAO,aAAaC,CAAQ,EAGrC,OADqB,KAAKD,CAAG,EACT,QAAQ,MAAO,GAAG,EAAE,QAAQ,MAAO,GAAG,EAAE,QAAQ,KAAM,EAAE,CAC9E,CACF,ECpKA,IAAME,EAAN,KAAoB,CAClB,aAAc,CACZ,KAAK,OAAS,SAAS,cAAc,iBAAiB,CACxD,CAEA,qBAAsB,CACpB,KAAK,OAAO,iBAAiB,gCAAgC,EAAE,QAASC,GAAeA,EAAW,iBAAiB,QAAU,GAAM,CACjI,IAAIC,EAAQ,EAAE,OAAO,MAGrB,KAAK,OAAO,iBAAiB,aAAa,EAAE,QAASC,GAAQ,CACvDA,EAAI,UAAU,SAASD,CAAK,EAC9BC,EAAI,MAAM,QAAU,GAEpBA,EAAI,MAAM,QAAU,MAExB,CAAC,EAGD,IAAIC,EACJ,OAAQF,EAAO,CACb,IAAK,OACL,IAAK,QACHE,EAAgBF,EAChB,MACF,QACEE,EAAgB,YACpB,CAEA,IAAMC,EAAe,OAAO,YAAc,OAAO,WAAW,8BAA8B,EAAE,QAAW,OAAS,QAC1GC,EAAO,SAAS,cAAc,MAAM,EACtCC,EAAeD,EAAK,MAAM,iBAAiB,cAAc,EACzDC,IAAiB,KACnBA,EAAe,cAIjB,IAAIC,EAAe,GAEfJ,IAAkBG,IAChBH,IAAkB,aACpBI,EAAeD,IAAiBF,EACvBE,IAAiB,aAC1BC,EAAeJ,IAAkBC,EAEjCG,EAAe,IAInBF,EAAK,MAAM,mBAAqB,iBAC5BE,GAAgB,SAAS,oBAC3B,SAAS,oBAAoB,IAAM,CAEjC,SAAS,cAAc,0BAA0B,EAAE,cAAc,EAGjEF,EAAK,MAAM,YAAY,eAAgBF,CAAa,CACtD,CAAC,GAGD,SAAS,cAAc,0BAA0B,EAAE,cAAc,EAGjEE,EAAK,MAAM,YAAY,eAAgBF,CAAa,EAExD,CAAC,CAAC,CACJ,CACF,EChEA,IAAIK,EAAO,IAAIC,EACf,SAAS,iBAAiB,cAAc,EAAE,QAASC,GAAO,CACxDA,EAAG,iBAAiB,QAAS,IAAM,CACjCF,EAAK,SAAS,CAChB,CAAC,CACH,CAAC,EAED,SAAS,iBAAiB,gBAAgB,EAAE,QAASE,GAAO,CAC1DA,EAAG,iBAAiB,QAAS,IAAM,CACjCF,EAAK,MAAM,CACb,CAAC,CACH,CAAC,EAED,IAAIG,EAAgB,IAAIC,EACxBD,EAAc,oBAAoB", - "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "radioInput", "theme", "svg", "selectedTheme", "systemTheme", "html", "currentTheme", "doTransition", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] + "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n this.btnLight = this.widget.querySelector('#btn-light');\n this.btnDark = this.widget.querySelector('#btn-dark');\n this.status = this.widget.querySelector('#theme-status');\n this.currentTheme = 'system';\n }\n\n setupEventListeners() {\n this.btnLight.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');\n });\n\n this.btnDark.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');\n });\n }\n\n applyTheme(theme) {\n const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.documentElement;\n let currentColorScheme = html.style.getPropertyValue('color-scheme');\n if (currentColorScheme === '') {\n currentColorScheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentColorScheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentColorScheme !== systemTheme;\n } else if (currentColorScheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n const applyChange = () => {\n this.currentTheme = theme;\n html.style.setProperty('color-scheme', selectedTheme);\n\n this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));\n this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));\n this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';\n };\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(applyChange);\n } else {\n applyChange();\n }\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], + "mappings": "MAAA,IAAMA,EAAN,KAAW,CACT,aAAc,CAAC,CAEf,MAAM,UAAW,CACf,IAAMC,EAAgB,MAAM,KAAK,iBAAiB,EAE5CC,EAAqC,CACzC,UAAW,KAAK,wBAAwBD,EAAc,SAAS,EAC/D,GAAI,CACF,GAAIA,EAAc,GAAG,GACrB,KAAMA,EAAc,GAAG,IACzB,EACA,KAAM,CACJ,GAAI,IAAI,YAAY,EAAE,OAAO,OAAO,KAAKA,EAAc,KAAK,EAAE,CAAC,EAC/D,KAAMA,EAAc,KAAK,KACzB,YAAaA,EAAc,KAAK,WAClC,EACA,iBAAkBA,EAAc,iBAChC,mBAAoB,CAAC,EACrB,uBAAwBA,EAAc,uBACtC,QAAS,GACX,EAEME,EAAa,MAAM,UAAU,YAAY,OAAO,CACpD,UAAWD,CACb,CAAC,EACD,GAAI,CAACC,EACH,MAAM,IAAI,MAAM,4BAA4B,EAG9C,IAAMC,EAAmC,CACvC,GAAID,EAAW,GAAKA,EAAW,GAAK,KACpC,KAAMA,EAAW,KAAOA,EAAW,KAAO,KAC1C,MAAOA,EAAW,MAAQ,KAAK,wBAAwBA,EAAW,KAAK,EAAI,KAC3E,SAAU,CACR,kBAAmBA,EAAW,SAAS,kBAAoB,KAAK,wBAAwBA,EAAW,SAAS,iBAAiB,EAAI,KACjI,eAAgBA,EAAW,SAAS,eAAiB,KAAK,wBAAwBA,EAAW,SAAS,cAAc,EAAI,IAC1H,CACF,EAYA,GAAI,EAVuB,MAAM,OAAO,MAAM,2BAA4B,CACxE,OAAQ,OACR,KAAM,KAAK,UAAUC,CAAgC,EACrD,MAAO,WACP,QAAS,CACP,eAAgB,mBAChB,eAAgB,SAAS,cAAc,yBAAyB,EAAE,aAAa,SAAS,CAC1F,CACF,CAAC,GAEuB,GACtB,MAAM,IAAI,MAAM,0BAA0B,EAG5C,OAAO,SAAS,OAAO,CACzB,CAEA,MAAM,kBAAmB,CAKvB,OAAO,MAJU,MAAM,MAAM,2BAA4B,CACvD,OAAQ,KACV,CAAC,GAEqB,KAAK,CAC7B,CAEA,MAAM,OAAQ,CACZ,IAAMC,EAAY,MAAM,KAAK,aAAa,EAEpCC,EAAsB,MAAM,UAAU,YAAY,IAAI,CAC1D,UAAW,CACT,UAAW,KAAK,wBAAwBD,EAAU,SAAS,EAC3D,iBAAkBA,EAAU,iBAC5B,QAAS,GACX,CACF,CAAC,EAED,GAAI,CAACC,EACH,MAAM,IAAI,MAAM,uBAAuB,EAGzC,IAAMF,EAAmC,CACvC,GAAIE,EAAoB,GAAKA,EAAoB,GAAK,GACtD,KAAMA,EAAoB,KAAOA,EAAoB,KAAO,GAC5D,MAAOA,EAAoB,MAAQ,KAAK,wBAAwBA,EAAoB,KAAK,EAAI,GAC7F,SAAU,CACR,kBAAmBA,EAAoB,SAAS,kBAAoB,KAAK,wBAAwBA,EAAoB,SAAS,iBAAiB,EAAI,GACnJ,eAAgBA,EAAoB,SAAS,eAAiB,KAAK,wBAAwBA,EAAoB,SAAS,cAAc,EAAI,GAC1I,UAAWA,EAAoB,SAAS,UAAY,KAAK,wBAAwBA,EAAoB,SAAS,SAAS,EAAI,GAC3H,WAAYA,EAAoB,SAAS,WAAa,KAAK,wBAAwBA,EAAoB,SAAS,UAAU,EAAI,EAChI,CACF,EAWA,GAAI,EATiB,MAAM,OAAO,MAAM,iBAAkB,CACxD,OAAQ,OACR,KAAM,KAAK,UAAUF,CAAgC,EACrD,QAAS,CACP,eAAgB,mBAChB,eAAgB,SAAS,cAAc,yBAAyB,EAAE,aAAa,SAAS,CAC1F,CACF,CAAC,GAEiB,GAChB,MAAM,IAAI,MAAM,cAAc,EAGhC,OAAO,SAAS,OAAO,QAAQ,CACjC,CAEA,MAAM,cAAe,CAKnB,OAAO,MAJU,MAAM,MAAM,iBAAkB,CAC7C,OAAQ,KACV,CAAC,GAEqB,KAAK,CAC7B,CAUA,wBAAwBG,EAAiB,CAEvC,IAAMC,EAASD,EAAgB,QAAQ,KAAM,GAAG,EAAE,QAAQ,KAAM,GAAG,EAQ7DE,GAAa,EAAKD,EAAO,OAAS,GAAM,EACxCE,EAASF,EAAO,OAAOA,EAAO,OAASC,EAAW,GAAG,EAErDE,EAAS,OAAO,KAAKD,CAAM,EAE3BE,EAAS,IAAI,YAAYD,EAAO,MAAM,EACtCE,EAAQ,IAAI,WAAWD,CAAM,EACnC,QAASE,EAAI,EAAGA,EAAIH,EAAO,OAAQG,IACjCD,EAAMC,CAAC,EAAIH,EAAO,WAAWG,CAAC,EAEhC,OAAOF,CACT,CAUA,wBAAwBA,EAAQ,CAC9B,IAAMC,EAAQ,IAAI,WAAWD,CAAM,EAC/BG,EAAM,GACV,QAAWC,KAAYH,EACrBE,GAAO,OAAO,aAAaC,CAAQ,EAGrC,OADqB,KAAKD,CAAG,EACT,QAAQ,MAAO,GAAG,EAAE,QAAQ,MAAO,GAAG,EAAE,QAAQ,KAAM,EAAE,CAC9E,CACF,ECpKA,IAAME,EAAN,KAAoB,CAClB,aAAc,CACZ,KAAK,OAAS,SAAS,cAAc,iBAAiB,EACtD,KAAK,SAAW,KAAK,OAAO,cAAc,YAAY,EACtD,KAAK,QAAU,KAAK,OAAO,cAAc,WAAW,EACpD,KAAK,OAAS,KAAK,OAAO,cAAc,eAAe,EACvD,KAAK,aAAe,QACtB,CAEA,qBAAsB,CACpB,KAAK,SAAS,iBAAiB,QAAS,IAAM,CAC5C,KAAK,WAAW,KAAK,eAAiB,QAAU,SAAW,OAAO,CACpE,CAAC,EAED,KAAK,QAAQ,iBAAiB,QAAS,IAAM,CAC3C,KAAK,WAAW,KAAK,eAAiB,OAAS,SAAW,MAAM,CAClE,CAAC,CACH,CAEA,WAAWC,EAAO,CAChB,IAAMC,EAAiBD,IAAU,SAAWA,IAAU,OAAUA,EAAQ,aAElEE,EAAe,OAAO,YAAc,OAAO,WAAW,8BAA8B,EAAE,QAAW,OAAS,QAC1GC,EAAO,SAAS,gBAClBC,EAAqBD,EAAK,MAAM,iBAAiB,cAAc,EAC/DC,IAAuB,KACzBA,EAAqB,cAIvB,IAAIC,EAAe,GAEfJ,IAAkBG,IAChBH,IAAkB,aACpBI,EAAeD,IAAuBF,EAC7BE,IAAuB,aAChCC,EAAeJ,IAAkBC,EAEjCG,EAAe,IAInB,IAAMC,EAAc,IAAM,CACxB,KAAK,aAAeN,EACpBG,EAAK,MAAM,YAAY,eAAgBF,CAAa,EAEpD,KAAK,SAAS,aAAa,eAAgB,OAAOD,IAAU,OAAO,CAAC,EACpE,KAAK,QAAQ,aAAa,eAAgB,OAAOA,IAAU,MAAM,CAAC,EAClE,KAAK,OAAO,YAAcA,IAAU,SAAW,8BAAgC,EACjF,EAEAG,EAAK,MAAM,mBAAqB,iBAC5BE,GAAgB,SAAS,oBAC3B,SAAS,oBAAoBC,CAAW,EAExCA,EAAY,CAEhB,CACF,ECvDA,IAAIC,EAAO,IAAIC,EACf,SAAS,iBAAiB,cAAc,EAAE,QAASC,GAAO,CACxDA,EAAG,iBAAiB,QAAS,IAAM,CACjCF,EAAK,SAAS,CAChB,CAAC,CACH,CAAC,EAED,SAAS,iBAAiB,gBAAgB,EAAE,QAASE,GAAO,CAC1DA,EAAG,iBAAiB,QAAS,IAAM,CACjCF,EAAK,MAAM,CACb,CAAC,CACH,CAAC,EAED,IAAIG,EAAgB,IAAIC,EACxBD,EAAc,oBAAoB", + "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "theme", "selectedTheme", "systemTheme", "html", "currentColorScheme", "doTransition", "applyChange", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] } diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst index e9a5163a..f9a71170 100644 Binary files a/public/assets/js/app.js.zst and b/public/assets/js/app.js.zst differ diff --git a/public/assets/js/snow-fall.js.br b/public/assets/js/snow-fall.js.br deleted file mode 100644 index b44b6670..00000000 Binary files a/public/assets/js/snow-fall.js.br and /dev/null differ diff --git a/public/assets/js/snow-fall.js.zst b/public/assets/js/snow-fall.js.zst deleted file mode 100644 index 6c26bd2a..00000000 Binary files a/public/assets/js/snow-fall.js.zst and /dev/null differ diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css new file mode 100644 index 00000000..7bf0ad02 --- /dev/null +++ b/resources/css/admin-tokens.css @@ -0,0 +1,163 @@ +@layer components { + .token-list-wrapper { + margin-block-start: 1em; + border: 1px solid var(--clr-border); + border-radius: 16px; + overflow: auto hidden; + background: light-dark( + oklch(99% 0.02 var(--primary-hue)), + oklch(22% 0.05 var(--primary-hue)) + ); + } + + .token-list { + width: 100%; + min-width: 640px; + table-layout: fixed; + border-collapse: collapse; + + th, + td { + text-align: left; + padding: 0.9em 1.2em; + border-bottom: 1px solid var(--clr-border); + vertical-align: middle; + } + + th { + font-size: 0.8em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.7; + } + + tr.is-revoked { + opacity: 0.6; + } + + a { + word-break: break-all; + } + + th:nth-child(1), + td:nth-child(1) { + width: 31%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 24%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 15%; + } + + th:nth-child(4), + td:nth-child(4) { + width: 16%; + } + + th:nth-child(5), + td:nth-child(5) { + width: 14%; + text-align: right; + } + + tr:last-child td { + border-bottom: none; + } + } + + .scope-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4em; + } + + .scope-chip { + display: inline-block; + padding: 0.2em 0.7em; + border-radius: 999px; + background: light-dark( + oklch(92% 0.05 var(--primary-hue)), + oklch(35% 0.08 var(--primary-hue)) + ); + font-size: 0.85em; + white-space: nowrap; + } + + .badge, + .token-list button.revoke { + display: inline-flex; + align-items: center; + gap: 0.4em; + padding: 0.3em 0.8em; + border-radius: 999px; + font-size: 0.85em; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; + } + + .badge { + background: transparent; + border: 1px solid currentcolor; + } + + .badge-active { + color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg)); + + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentcolor; + } + } + + .badge-revoked { + color: var(--clr-text); + opacity: 0.7; + } + + .token-list button.revoke { + background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg)); + color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg)); + border: 1px solid transparent; + cursor: pointer; + transition: background-color 150ms ease; + } + + .token-list button.revoke:hover { + background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg)); + } + + .token-reveal { + margin-block-end: 1em; + padding: 1em 1.2em; + border: 1px solid var(--clr-border); + border-radius: 16px; + background: light-dark( + oklch(96% 0.08 145deg), + oklch(28% 0.08 145deg) + ); + + input { + width: 100%; + font-family: monospace; + padding: 0.5em 0.7em; + border-radius: 8px; + border: 1px solid var(--clr-border); + } + } + + .scope-checkboxes { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1em; + } +} diff --git a/resources/css/app.css b/resources/css/app.css index f25e47e3..1f787028 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,3 +8,4 @@ @import url('notes.css'); @import url('pagination.css'); @import url('theme-selector.css'); +@import url('admin-tokens.css'); diff --git a/resources/css/layout.css b/resources/css/layout.css index dc5f753f..44fda96f 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -18,7 +18,17 @@ } > main { - grid-column: 2/3; + grid-column: 1/-1; + display: grid; + grid-template-columns: subgrid; + + > * { + grid-column: 2/3; + } + + > .full-bleed { + grid-column: 1/-1; + } } > footer { diff --git a/resources/css/theme-selector.css b/resources/css/theme-selector.css index 333639a4..f718b4b0 100644 --- a/resources/css/theme-selector.css +++ b/resources/css/theme-selector.css @@ -1,72 +1,68 @@ @layer components { #theme-selector { + display: flex; + justify-self: start; + align-items: center; + gap: .25rem; + padding: .25rem; + border-radius: 999px; + background-color: color-mix(in oklch, var(--clr-border) 40%, transparent); + button { + display: grid; + place-items: center; appearance: none; border: none; - background: none; + border-radius: 999px; + padding: .375rem; + background: transparent; + color: inherit; + transition: background-color .2s, color .2s; &:hover { cursor: pointer; } - } - /* This is the element with the [popover] attribute */ - #theme-selector-dropdown { - position: absolute; - position-area: block-end span-inline-start; - margin: 0; - border: 2px solid var(--clr-border); - background-color: var(--clr-background); - color: var(--clr-text); - - fieldset { - border: 0; - display: flex; - flex-direction: row; - gap: 1ch; - - input { - display: none; - } + &:focus-visible { + outline: 2px solid var(--clr-text); + outline-offset: 2px; } - } - /* - * This is the element with the [popover] attribute - * Here we are styling the open and closing animations - */ - @media (prefers-reduced-motion: no-preference) { - #theme-selector-dropdown { - &:popover-open { - transform: translateY(0) scale(1); - opacity: 1; + svg { + width: 1.5rem; + height: 1.5rem; + fill: currentcolor; + } - /* - * Start styles for the opening transition. - * Added to :popover-open, but after the opened styles. - */ - @starting-style { - transform: translateY(30px) scale(0); - opacity: 0; - } + @media (hover: hover) { + &:hover { + background-color: color-mix(in oklch, var(--clr-border) 70%, transparent); } - /* - * End styles for the closing transition. - */ - transform: translateY(0) scale(0); - opacity: 0; + &[aria-pressed="true"]:hover { + background-color: var(--clr-text); + } + } - /* - * Enumerate transitioning properties, including display and overlay. - */ - transition: transform, opacity, display allow-discrete, overlay allow-discrete; - transition-duration: 0.5s; - transform-origin: top right; + &[aria-pressed="true"] { + background-color: var(--clr-text); + color: var(--clr-background); } } } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + ::view-transition-group(changing-theme) { animation-duration: 1.25s; } diff --git a/resources/js/theme-selector.js b/resources/js/theme-selector.js index 3248b9bf..374fdc8c 100644 --- a/resources/js/theme-selector.js +++ b/resources/js/theme-selector.js @@ -1,69 +1,60 @@ class ThemeSelector { constructor() { this.widget = document.querySelector('#theme-selector'); + this.btnLight = this.widget.querySelector('#btn-light'); + this.btnDark = this.widget.querySelector('#btn-dark'); + this.status = this.widget.querySelector('#theme-status'); + this.currentTheme = 'system'; } setupEventListeners() { - this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => { - let theme = e.target.value; + this.btnLight.addEventListener('click', () => { + this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light'); + }); - // Update current icon - this.widget.querySelectorAll('.toggle svg').forEach((svg) => { - if (svg.classList.contains(theme)) { - svg.style.display = ''; - } else { - svg.style.display = 'none'; - } - }); + this.btnDark.addEventListener('click', () => { + this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark'); + }); + } - // Set the theme - let selectedTheme; - switch (theme) { - case 'dark': - case 'light': - selectedTheme = theme; - break; - default: - selectedTheme = 'light dark'; - } + applyTheme(theme) { + const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark'; - const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; - const html = document.querySelector('html'); - let currentTheme = html.style.getPropertyValue('color-scheme'); - if (currentTheme === '') { - currentTheme = 'light dark'; - } + const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + const html = document.documentElement; + let currentColorScheme = html.style.getPropertyValue('color-scheme'); + if (currentColorScheme === '') { + currentColorScheme = 'light dark'; + } - /* do we need to transition */ - let doTransition = false; + /* do we need to transition */ + let doTransition = false; - if (selectedTheme !== currentTheme) { - if (selectedTheme === 'light dark') { - doTransition = currentTheme !== systemTheme; - } else if (currentTheme === 'light dark') { - doTransition = selectedTheme !== systemTheme; - } else { - doTransition = true; - } - } - - html.style.viewTransitionName = 'changing-theme'; - if (doTransition && document.startViewTransition) { - document.startViewTransition(() => { - // Close the popover - document.querySelector('#theme-selector-dropdown').togglePopover(); - - // Set the colour theme - html.style.setProperty('color-scheme', selectedTheme); - }); + if (selectedTheme !== currentColorScheme) { + if (selectedTheme === 'light dark') { + doTransition = currentColorScheme !== systemTheme; + } else if (currentColorScheme === 'light dark') { + doTransition = selectedTheme !== systemTheme; } else { - // Close the popover - document.querySelector('#theme-selector-dropdown').togglePopover(); - - // Set the colour theme - html.style.setProperty('color-scheme', selectedTheme); + doTransition = true; } - })); + } + + const applyChange = () => { + this.currentTheme = theme; + html.style.setProperty('color-scheme', selectedTheme); + + this.btnLight.setAttribute('aria-pressed', String(theme === 'light')); + this.btnDark.setAttribute('aria-pressed', String(theme === 'dark')); + this.status.textContent = theme === 'system' ? 'Theme set to system default' : ''; + }; + + html.style.viewTransitionName = 'changing-theme'; + if (doTransition && document.startViewTransition) { + document.startViewTransition(applyChange); + } else { + applyChange(); + } } } diff --git a/resources/views/about.blade.php b/resources/views/about.blade.php new file mode 100644 index 00000000..e73c933a --- /dev/null +++ b/resources/views/about.blade.php @@ -0,0 +1,9 @@ +@extends('master') +@section('title')About « @stop + +@section('content') +

About

+
+ {!! $about !!} +
+@stop diff --git a/resources/views/admin/about/show.blade.php b/resources/views/admin/about/show.blade.php new file mode 100644 index 00000000..59c6a433 --- /dev/null +++ b/resources/views/admin/about/show.blade.php @@ -0,0 +1,19 @@ +@extends('master') + +@section('title')Edit About « Admin CP « @stop + +@section('content') +

Edit About

+
+ {{ csrf_field() }} + {{ method_field('PUT') }} +
+ +
+ +
+
+ +
+
+@stop diff --git a/resources/views/admin/settings/show.blade.php b/resources/views/admin/settings/show.blade.php new file mode 100644 index 00000000..801171e1 --- /dev/null +++ b/resources/views/admin/settings/show.blade.php @@ -0,0 +1,26 @@ +@extends('master') + +@section('title')Site Settings « Admin CP « @stop + +@section('content') +

Site settings

+
+ {{ csrf_field() }} + {{ method_field('PUT') }} +
+ +
+
+ +
+
+@stop diff --git a/resources/views/admin/tokens/create.blade.php b/resources/views/admin/tokens/create.blade.php new file mode 100644 index 00000000..d4847d1e --- /dev/null +++ b/resources/views/admin/tokens/create.blade.php @@ -0,0 +1,52 @@ +@extends('master') + +@section('title')New Token « Admin CP « @stop + +@section('content') +

Generate a new token

+

Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.

+ +
+ {{ csrf_field() }} + +
+ + +
+ +
+ Scope + + +
+ +
+ +
+
+@stop diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php new file mode 100644 index 00000000..33806cdd --- /dev/null +++ b/resources/views/admin/tokens/index.blade.php @@ -0,0 +1,64 @@ +@extends('master') + +@section('title')List Tokens « Admin CP « @stop + +@section('content') +

Micropub Tokens

+

Generate new token

+ + @if(session('new_token')) +
+

Here's your new token. Copy it now — it won't be shown again.

+ +
+ @endif + + @if($tokens->isEmpty()) +

No tokens have been issued.

+ @else +
+ + + + + + + + + + + + @foreach($tokens as $token) + + + + + + + + @endforeach + +
ClientScopeIssuedStatusAction
{{ $token->client_id }} + + @foreach(explode(' ', $token->scope) as $scope) + {{ $scope }} + @endforeach + + + @if($token->isRevoked) + Revoked {{ $token->revoked_at->diffForHumans() }} + @else + Active + @endif + + @unless($token->isRevoked) +
+ {{ csrf_field() }} + {{ method_field('PUT') }} + +
+ @endunless +
+
+ @endif +@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 269ccdc5..132da7cc 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -47,13 +47,28 @@ or edit them.

+

Tokens

+

+ View and revoke issued Micropub tokens. +

+

Bio

Edit your bio.

+

About

+

+ Edit your about page. +

+

Passkeys

Manager your passkeys.

+ +

Settings

+

+ Edit site settings. +

@stop diff --git a/resources/views/articles/atom.blade.php b/resources/views/articles/atom.blade.php deleted file mode 100644 index 9892bcfb..00000000 --- a/resources/views/articles/atom.blade.php +++ /dev/null @@ -1,20 +0,0 @@ - - - Atom feed for {{ config('user.display_name') }}’s blog - - {{ config('app.url')}}/blog - {{ $articles[0]->updated_at->toAtomString() }} - -@foreach($articles as $article) - - {{ $article->title }} - - {{ config('app.url') }}{{ $article->link }} - {{ $article->updated_at->toAtomString() }} - {{ $article->main }} - - {{ config('user.display_name') }} - - -@endforeach - diff --git a/resources/views/articles/rss.blade.php b/resources/views/articles/rss.blade.php deleted file mode 100644 index 00268681..00000000 --- a/resources/views/articles/rss.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - {{ config('user.display_name') }} - - An RSS feed of the blog posts found on {{ config('app.url') }} - {{ config('app.url') }}/blog - {{ $buildDate }} - 1800 - -@foreach($articles as $article) - - {{ strip_tags($article->title) }} - - main }} - @if($article->url)

Permalink

@endif - ]]> -
- @if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif - {{ config('app.url') }}{{ $article->link }} - {{ $article->pubdate }} -
-@endforeach -
-
diff --git a/resources/views/icons/json-feed.blade.php b/resources/views/icons/json-feed.blade.php new file mode 100644 index 00000000..7b8df856 --- /dev/null +++ b/resources/views/icons/json-feed.blade.php @@ -0,0 +1,11 @@ +@php +if (isset($title)) { + $uniqueId = bin2hex(random_bytes(6)); +} +@endphp + + @if($title){{ $title }}@endif + + diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 2e5c6b5a..b33d1fa9 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -7,13 +7,9 @@ @yield('title'){{ config('app.name') }} - - - + - - - + @@ -40,37 +36,17 @@ Likes Contacts Projects - @include('icons.rss', ['title' => 'RSS Feed']) + About + @include('icons.json-feed', ['title' => 'JSON Feed']) -
- -
-
- Select theme: -
- - -
-
- - -
-
- - -
-
-
+ +
@@ -106,14 +82,16 @@ @section('scripts') - - - - - + @if($winterEffectEnabled ?? false) + + + + + + @endif @show diff --git a/resources/views/notes/atom.blade.php b/resources/views/notes/atom.blade.php deleted file mode 100644 index c84a4a93..00000000 --- a/resources/views/notes/atom.blade.php +++ /dev/null @@ -1,20 +0,0 @@ - - - Atom feed for {{ config('user.display_name') }}’s notes - - {{ config('app.url')}}/notes - {{ $notes[0]->updated_at->toAtomString() }} - -@foreach($notes as $note) - - {{ strip_tags($note->note) }} - - {{ $note->uri }} - {{ $note->updated_at->toAtomString() }} - {{ $note->note }} - - {{ config('user.display_name') }} - - -@endforeach - diff --git a/resources/views/notes/rss.blade.php b/resources/views/notes/rss.blade.php deleted file mode 100644 index 9146ebe0..00000000 --- a/resources/views/notes/rss.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - {{ config('user.display_name') }} - - An RSS feed of the notes found on {{ config('app.url') }} - {{ config('app.url') }}/notes - {{ $buildDate }} - 1800 - -@foreach($notes as $note) - - {{ strip_tags($note->note) }} - - note !!} - ]]> - - {{ $note->uri }} - {{ $note->uri}} - {{ $note->pubdate }} - -@endforeach - - - diff --git a/routes/web.php b/routes/web.php index 953b51ac..a9b46407 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('login'); Route::post('login', [AuthController::class, 'login']); @@ -147,12 +154,32 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { Route::delete('/{clientId}', [ClientsController::class, 'destroy']); }); + // Micropub Tokens + Route::prefix('tokens')->group(function () { + Route::get('/', [TokensController::class, 'index']); + Route::get('/create', [TokensController::class, 'create']); + Route::post('/', [TokensController::class, 'store']); + Route::put('/{token}/revoke', [TokensController::class, 'revoke']); + }); + // Bio Route::prefix('bio')->group(function () { Route::get('/', [BioController::class, 'show'])->name('admin.bio.show'); Route::put('/', [BioController::class, 'update']); }); + // Settings + Route::prefix('settings')->group(function () { + Route::get('/', [SettingsController::class, 'show'])->name('admin.settings.show'); + Route::put('/', [SettingsController::class, 'update']); + }); + + // About + Route::prefix('about')->group(function () { + Route::get('/', [AboutController::class, 'show'])->name('admin.about.show'); + Route::put('/', [AboutController::class, 'update']); + }); + // Passkeys Route::prefix('passkeys')->group(function () { Route::get('/', [PasskeysController::class, 'index']); @@ -163,8 +190,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { // Blog pages using ArticlesController Route::prefix('blog')->group(function () { - Route::get('/feed.rss', [FeedsController::class, 'blogRss'])->name('feed.blog.rss'); - Route::get('/feed.atom', [FeedsController::class, 'blogAtom'])->name('feed.blog.atom'); Route::get('/feed.json', [FeedsController::class, 'blogJson'])->name('feed.blog.json'); Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2'); Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']); @@ -175,8 +200,6 @@ Route::prefix('blog')->group(function () { // Notes pages using NotesController Route::prefix('notes')->group(function () { Route::get('/', [NotesController::class, 'index']); - Route::get('/feed.rss', [FeedsController::class, 'notesRss'])->name('feed.notes.rss'); - Route::get('/feed.atom', [FeedsController::class, 'notesAtom'])->name('feed.notes.atom'); Route::get('/feed.json', [FeedsController::class, 'notesJson'])->name('feed.notes.json'); Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2'); Route::get('/new', [NotesController::class, 'create']); @@ -205,6 +228,8 @@ Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMidd Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class); Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token'); +Route::post('revocation', [IndieAuthController::class, 'processRevocationRequest'])->name('indieauth.revocation'); +Route::post('introspect', [IndieAuthController::class, 'processIntrospectionRequest'])->name('indieauth.introspection'); // Micropub Endpoints Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); diff --git a/tests/Feature/AboutPageTest.php b/tests/Feature/AboutPageTest.php new file mode 100644 index 00000000..61455069 --- /dev/null +++ b/tests/Feature/AboutPageTest.php @@ -0,0 +1,26 @@ +create([ + 'content' => 'This is the about page content.', + ]); + + $this->get('/about') + ->assertSee('This is the about page content.'); + } +} diff --git a/tests/Feature/Admin/AboutTest.php b/tests/Feature/Admin/AboutTest.php new file mode 100644 index 00000000..141a548b --- /dev/null +++ b/tests/Feature/Admin/AboutTest.php @@ -0,0 +1,68 @@ +make(); + + $response = $this->actingAs($user) + ->get('/admin/about'); + $response->assertSeeText('Edit About'); + } + + #[Test] + public function admin_can_create_about(): void + { + $user = User::factory()->make(); + + $this->actingAs($user) + ->post('/admin/about', [ + '_method' => 'PUT', + 'content' => 'About content', + ]); + $this->assertDatabaseHas('about', ['content' => 'About content']); + } + + #[Test] + public function admin_can_load_existing_about(): void + { + $user = User::factory()->make(); + $about = About::factory()->create([ + 'content' => 'This is my about page. It uses HTML.', + ]); + + $response = $this->actingAs($user) + ->get('/admin/about'); + $response->assertSeeText('This is my about page. It uses HTML.'); + } + + #[Test] + public function admin_can_edit_about(): void + { + $user = User::factory()->make(); + $about = About::factory()->create(); + + $this->actingAs($user) + ->post('/admin/about', [ + '_method' => 'PUT', + 'content' => 'This about page has been edited', + ]); + $this->assertDatabaseHas('about', [ + 'content' => 'This about page has been edited', + ]); + } +} diff --git a/tests/Feature/Admin/ContactsTest.php b/tests/Feature/Admin/ContactsTest.php index 9fc338eb..44320b9d 100644 --- a/tests/Feature/Admin/ContactsTest.php +++ b/tests/Feature/Admin/ContactsTest.php @@ -6,12 +6,9 @@ namespace Tests\Feature\Admin; use App\Models\Contact; use App\Models\User; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -141,14 +138,12 @@ class ContactsTest extends TestCase HTML; - $file = fopen(__DIR__.'/../../aaron.png', 'rb'); - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'text/html'], $html), - new Response(200, ['Content-Type' => 'image/png'], $file), + $file = file_get_contents(__DIR__.'/../../aaron.png'); + Http::fake([ + '*' => Http::sequence() + ->push($html, 200, ['Content-Type' => 'text/html']) + ->push($file, 200, ['Content-Type' => 'image/png']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $user = User::factory()->make(); $contact = Contact::factory()->create([ 'homepage' => 'https://tantek.com', @@ -165,12 +160,9 @@ class ContactsTest extends TestCase #[Test] public function getting_remote_avatar_fails_gracefully_with_remote_not_found(): void { - $mock = new MockHandler([ - new Response(404), + Http::fake([ + '*' => Http::response('', 404), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $user = User::factory()->make(); $contact = Contact::factory()->create(); @@ -187,13 +179,11 @@ class ContactsTest extends TestCase HTML; - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'text/html'], $html), - new Response(404), + Http::fake([ + '*' => Http::sequence() + ->push($html, 200, ['Content-Type' => 'text/html']) + ->push('', 404), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $user = User::factory()->make(); $contact = Contact::factory()->create(); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..bf7d068b --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,68 @@ +make(); + + $response = $this->actingAs($user) + ->get('/admin/settings'); + $response->assertSeeText('Site settings'); + } + + #[Test] + public function admin_can_enable_winter_effect(): void + { + $user = User::factory()->make(); + + $this->actingAs($user) + ->post('/admin/settings', [ + '_method' => 'PUT', + 'winter_effect_enabled' => '1', + ]); + $this->assertDatabaseHas('settings', ['winter_effect_enabled' => true]); + } + + #[Test] + public function admin_can_disable_winter_effect(): void + { + $user = User::factory()->make(); + Setting::factory()->create(['winter_effect_enabled' => true]); + + $this->actingAs($user) + ->post('/admin/settings', [ + '_method' => 'PUT', + ]); + $this->assertDatabaseHas('settings', ['winter_effect_enabled' => false]); + } + + #[Test] + public function winter_effect_markup_is_hidden_when_disabled(): void + { + $response = $this->get('/'); + $response->assertDontSee('snow-fall'); + } + + #[Test] + public function winter_effect_markup_is_shown_when_enabled(): void + { + Setting::factory()->create(['winter_effect_enabled' => true]); + + $response = $this->get('/'); + $response->assertSee('snow-fall', false); + } +} diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php new file mode 100644 index 00000000..d6695a15 --- /dev/null +++ b/tests/Feature/Admin/TokensTest.php @@ -0,0 +1,154 @@ +get('/admin/tokens'); + $response->assertRedirect(); + } + + #[Test] + public function index_lists_issued_tokens(): void + { + $user = User::factory()->make(); + $token = MicropubToken::create([ + 'token_hash' => hash('sha256', 'a-token'), + 'client_id' => 'https://quill.p3k.io', + 'me' => 'https://jonnybarnes.uk', + 'scope' => 'create update', + ]); + + $response = $this->actingAs($user)->get('/admin/tokens'); + $response->assertOk(); + $response->assertSeeText($token->client_id); + } + + #[Test] + public function create_requires_authentication(): void + { + $response = $this->get('/admin/tokens/create'); + $response->assertRedirect(); + } + + #[Test] + public function create_shows_form(): void + { + $user = User::factory()->make(); + + $response = $this->actingAs($user)->get('/admin/tokens/create'); + + $response->assertOk(); + $response->assertSee('name="client_id"', false); + } + + #[Test] + public function store_requires_authentication(): void + { + $response = $this->post('/admin/tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => ['create'], + ]); + + $response->assertRedirect(); + $this->assertDatabaseCount('micropub_tokens', 0); + } + + #[Test] + public function store_creates_a_new_token_and_redirects_with_it_flashed(): void + { + $user = User::factory()->make(); + + $response = $this->actingAs($user)->post('/admin/tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => ['create', 'update'], + ]); + + $response->assertRedirect('/admin/tokens'); + $response->assertSessionHas('new_token'); + + $this->assertDatabaseHas('micropub_tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => 'create update', + 'me' => config('app.url'), + ]); + + $token = $response->getSession()->get('new_token'); + $this->assertNotNull(MicropubToken::findActive($token)); + } + + #[Test] + public function store_requires_at_least_one_scope(): void + { + $user = User::factory()->make(); + + $response = $this->actingAs($user)->post('/admin/tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => [], + ]); + + $response->assertSessionHasErrors('scope'); + $this->assertDatabaseCount('micropub_tokens', 0); + } + + #[Test] + public function revoke_requires_authentication(): void + { + $token = MicropubToken::create([ + 'token_hash' => hash('sha256', 'a-token'), + 'client_id' => 'https://quill.p3k.io', + 'me' => 'https://jonnybarnes.uk', + 'scope' => 'create', + ]); + + $response = $this->put("/admin/tokens/{$token->id}/revoke"); + $response->assertRedirect(); + + $this->assertFalse($token->fresh()->isRevoked); + } + + #[Test] + public function revoke_marks_the_token_as_revoked(): void + { + $user = User::factory()->make(); + $token = MicropubToken::create([ + 'token_hash' => hash('sha256', 'a-token'), + 'client_id' => 'https://quill.p3k.io', + 'me' => 'https://jonnybarnes.uk', + 'scope' => 'create', + ]); + + $this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke"); + + $this->assertTrue($token->fresh()->isRevoked); + } + + #[Test] + public function revoke_redirects_to_index(): void + { + $user = User::factory()->make(); + $token = MicropubToken::create([ + 'token_hash' => hash('sha256', 'a-token'), + 'client_id' => 'https://quill.p3k.io', + 'me' => 'https://jonnybarnes.uk', + 'scope' => 'create', + ]); + + $response = $this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke"); + + $response->assertRedirect('/admin/tokens'); + } +} diff --git a/tests/Feature/CsrfExemptionsTest.php b/tests/Feature/CsrfExemptionsTest.php new file mode 100644 index 00000000..c03a3c2c --- /dev/null +++ b/tests/Feature/CsrfExemptionsTest.php @@ -0,0 +1,29 @@ +app->make(PreventRequestForgery::class)->getExcludedPaths(); + + foreach (['auth', 'token', 'revocation', 'introspect', 'api/post', 'api/media', 'micropub/places', 'webmention'] as $path) { + $this->assertContains($path, $exemptions); + } + } +} diff --git a/tests/Feature/FeedsTest.php b/tests/Feature/FeedsTest.php index 0323aa73..3f168b58 100644 --- a/tests/Feature/FeedsTest.php +++ b/tests/Feature/FeedsTest.php @@ -15,42 +15,6 @@ class FeedsTest extends TestCase { use RefreshDatabase; - /** - * Test the blog RSS feed. - */ - #[Test] - public function blog_rss_feed_is_present(): void - { - Article::factory()->count(3)->create(); - $response = $this->get('/blog/feed.rss'); - $response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); - $response->assertOk(); - } - - /** - * Test the notes RSS feed. - */ - #[Test] - public function notes_rss_feed_is_present(): void - { - Note::factory()->count(3)->create(); - $response = $this->get('/notes/feed.rss'); - $response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); - $response->assertOk(); - } - - /** - * Test the blog RSS feed. - */ - #[Test] - public function blog_atom_feed_is_present(): void - { - Article::factory()->count(3)->create(); - $response = $this->get('/blog/feed.atom'); - $response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8'); - $response->assertOk(); - } - #[Test] public function blog_jf2_feed_is_present(): void { @@ -73,18 +37,6 @@ class FeedsTest extends TestCase ]); } - /** - * Test the notes RSS feed. - */ - #[Test] - public function notes_atom_feed_is_present(): void - { - Note::factory()->count(3)->create(); - $response = $this->get('/notes/feed.atom'); - $response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8'); - $response->assertOk(); - } - /** * Test the blog JSON feed. */ @@ -93,7 +45,7 @@ class FeedsTest extends TestCase { Article::factory()->count(3)->create(); $response = $this->get('/blog/feed.json'); - $response->assertHeader('Content-Type', 'application/json'); + $response->assertHeader('Content-Type', 'application/feed+json'); $response->assertOk(); } @@ -105,7 +57,7 @@ class FeedsTest extends TestCase { Note::factory()->count(3)->create(); $response = $this->get('/notes/feed.json'); - $response->assertHeader('Content-Type', 'application/json'); + $response->assertHeader('Content-Type', 'application/feed+json'); $response->assertOk(); } diff --git a/tests/Feature/HeaderLinkTest.php b/tests/Feature/HeaderLinkTest.php index 874731a5..3983b02c 100644 --- a/tests/Feature/HeaderLinkTest.php +++ b/tests/Feature/HeaderLinkTest.php @@ -19,7 +19,9 @@ class HeaderLinkTest extends TestCase $this->assertSame('<'.config('app.url').'/.well-known/indieauth-server>; rel="indieauth-metadata"', $linkHeaders[0]); $this->assertSame('<'.config('app.url').'/auth>; rel="authorization_endpoint"', $linkHeaders[1]); $this->assertSame('<'.config('app.url').'/token>; rel="token_endpoint"', $linkHeaders[2]); - $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[3]); - $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[4]); + $this->assertSame('<'.config('app.url').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]); + $this->assertSame('<'.config('app.url').'/introspect>; rel="introspection_endpoint"', $linkHeaders[4]); + $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[5]); + $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[6]); } } diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index 534fe452..08282228 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -4,15 +4,14 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\MicropubToken; use App\Models\User; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; +use App\Services\TokenService; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\UriResolver; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -30,9 +29,10 @@ class IndieAuthTest extends TestCase 'issuer' => config('app.url'), 'authorization_endpoint' => route('indieauth.start'), 'token_endpoint' => route('indieauth.token'), + 'revocation_endpoint' => route('indieauth.revocation'), + 'introspection_endpoint' => route('indieauth.introspection'), + 'introspection_endpoint_auth_methods_supported' => ['Bearer'], 'code_challenge_methods_supported' => ['S256'], - // 'introspection_endpoint' => 'introspection_endpoint', - // 'introspection_endpoint_auth_methods_supported' => ['none'], ]); } @@ -269,12 +269,9 @@ class IndieAuthTest extends TestCase HTML; - $mockHandler = new MockHandler([ - new Response(200, [], $appPageHtml), + Http::fake([ + '*' => Http::response($appPageHtml, 200), ]); - $handlerStack = HandlerStack::create($mockHandler); - $mockGuzzleClient = new Client(['handler' => $handlerStack]); - $this->app->instance(Client::class, $mockGuzzleClient); $user = User::factory()->make(); $url = url()->query('/auth', [ @@ -313,12 +310,9 @@ class IndieAuthTest extends TestCase HTML; - $mockHandler = new MockHandler([ - new Response(200, [], $appPageHtml), + Http::fake([ + '*' => Http::response($appPageHtml, 200), ]); - $handlerStack = HandlerStack::create($mockHandler); - $mockGuzzleClient = new Client(['handler' => $handlerStack]); - $this->app->instance(Client::class, $mockGuzzleClient); $user = User::factory()->make(); $url = url()->query('/auth', [ @@ -701,4 +695,132 @@ class IndieAuthTest extends TestCase 'me' => config('app.url'), ]); } + + #[Test] + public function it_should_revoke_a_known_token(): void + { + $token = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => 'https://app.example.com', + 'scope' => 'create', + ]); + + $response = $this->post('/revocation', ['token' => $token]); + $response->assertStatus(200); + + $this->assertTrue( + MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->isRevoked + ); + } + + #[Test] + public function it_should_return200_for_an_unknown_token(): void + { + $response = $this->post('/revocation', ['token' => bin2hex(random_bytes(32))]); + + $response->assertStatus(200); + } + + #[Test] + public function introspection_requires_a_bearer_token(): void + { + $response = $this->post('/introspect', ['token' => 'irrelevant']); + + $response->assertStatus(401); + } + + #[Test] + public function introspection_rejects_a_revoked_bearer_token(): void + { + $callerToken = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => 'https://app.example.com', + 'scope' => 'create', + ]); + MicropubToken::where('token_hash', hash('sha256', $callerToken))->firstOrFail()->revoke(); + + $response = $this->post( + '/introspect', + ['token' => 'irrelevant'], + ['HTTP_Authorization' => 'Bearer '.$callerToken] + ); + + $response->assertStatus(401); + } + + #[Test] + public function introspection_returns_active_details_for_a_valid_token(): void + { + $callerToken = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => 'https://app.example.com', + 'scope' => 'create', + ]); + $subjectToken = resolve(TokenService::class)->getNewToken([ + 'me' => 'https://someone-else.example.com', + 'client_id' => 'https://quill.p3k.io', + 'scope' => 'create update', + ]); + + $response = $this->post( + '/introspect', + ['token' => $subjectToken], + ['HTTP_Authorization' => 'Bearer '.$callerToken] + ); + + $response->assertStatus(200); + $response->assertJson([ + 'active' => true, + 'me' => 'https://someone-else.example.com', + 'client_id' => 'https://quill.p3k.io', + 'scope' => 'create update', + ]); + $response->assertJsonStructure(['iat']); + } + + #[Test] + public function introspection_returns_only_active_false_for_an_unknown_token(): void + { + $callerToken = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => 'https://app.example.com', + 'scope' => 'create', + ]); + + $response = $this->post( + '/introspect', + ['token' => bin2hex(random_bytes(32))], + ['HTTP_Authorization' => 'Bearer '.$callerToken] + ); + + $response->assertStatus(200); + $response->assertExactJson(['active' => false]); + } + + #[Test] + public function revocation_does_not_error_on_an_array_shaped_token_param(): void + { + $response = $this->post('/revocation', ['token' => ['a', 'b']]); + + $response->assertStatus(200); + } + + #[Test] + public function introspection_does_not_error_on_an_array_shaped_token_param(): void + { + $callerToken = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => 'https://app.example.com', + 'scope' => 'create', + ]); + + $response = $this->post( + '/introspect', + ['token' => ['a', 'b']], + ['HTTP_Authorization' => 'Bearer '.$callerToken] + ); + + $response->assertStatus(200); + $response->assertExactJson(['active' => false]); + } } diff --git a/tests/Feature/LikesTest.php b/tests/Feature/LikesTest.php index 6101536c..2bda91f3 100644 --- a/tests/Feature/LikesTest.php +++ b/tests/Feature/LikesTest.php @@ -6,11 +6,8 @@ namespace Tests\Feature; use App\Jobs\ProcessLike; use App\Models\Like; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Jonnybarnes\WebmentionsParser\Authorship; use PHPUnit\Framework\Attributes\Test; @@ -98,18 +95,12 @@ class LikesTest extends TestCase END; - $mock = new MockHandler([ - new Response(200, [], $content), - new Response(200, [], $content), + Http::fake([ + '*' => Http::response($content, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->bind(Client::class, function () use ($client) { - return $client; - }); $authorship = new Authorship; - $job->handle($client, $authorship); + $job->handle($authorship); $this->assertEquals('Fred Bloggs', Like::find($id)->author_name); } @@ -141,18 +132,12 @@ class LikesTest extends TestCase END; - $mock = new MockHandler([ - new Response(200, [], $content), - new Response(200, [], $content), + Http::fake([ + '*' => Http::response($content, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->bind(Client::class, function () use ($client) { - return $client; - }); $authorship = new Authorship; - $job->handle($client, $authorship); + $job->handle($authorship); $this->assertEquals('Fred Bloggs', Like::find($id)->author_name); } @@ -177,18 +162,12 @@ class LikesTest extends TestCase END; - $mock = new MockHandler([ - new Response(200, [], $content), - new Response(200, [], $content), + Http::fake([ + '*' => Http::response($content, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->bind(Client::class, function () use ($client) { - return $client; - }); $authorship = new Authorship; - $job->handle($client, $authorship); + $job->handle($authorship); $this->assertNull(Like::find($id)->author_name); } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 1e9f0351..3fd8b515 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,14 +4,17 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; +use App\Models\Article; use App\Models\Media; use App\Models\Note; use App\Models\Place; use App\Models\SyndicationTarget; use Faker\Factory; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; @@ -457,6 +460,11 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void { + $this->mock(ExceptionHandler::class) + ->shouldReceive('report') + ->once() + ->with(\Mockery::type(MicropubHandlerException::class)); + $response = $this->postJson( '/api/post', [ @@ -869,6 +877,99 @@ class MicropubControllerTest extends TestCase $this->assertDatabaseHas('articles', [ 'title' => $name, 'main' => $content, + 'published' => true, + ]); + $response->assertHeader('Location'); + $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location')); + } + + #[Test] + public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void + { + $faker = Factory::create(); + $name = $faker->text(50); + $content = $faker->paragraphs(5, true); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => [$name], + 'content' => [$content], + 'post-status' => ['draft'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['response' => 'created']) + ->assertStatus(201); + $this->assertDatabaseHas('articles', [ + 'title' => $name, + 'main' => $content, + 'published' => false, ]); } + + #[Test] + public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void + { + $draft = Article::create([ + 'title' => 'WireGuard', + 'main' => 'Early draft content', + 'published' => false, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Finished content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['response' => 'created']) + ->assertStatus(201); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + $this->assertDatabaseHas('articles', [ + 'id' => $draft->id, + 'title' => 'WireGuard', + 'main' => 'Finished content', + 'published' => true, + ]); + } + + #[Test] + public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void + { + Article::create([ + 'title' => 'WireGuard', + 'main' => 'Published content', + 'published' => true, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Some other content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + } } diff --git a/tests/Feature/TokenServiceTest.php b/tests/Feature/TokenServiceTest.php index 7fe9e854..91b9e81d 100644 --- a/tests/Feature/TokenServiceTest.php +++ b/tests/Feature/TokenServiceTest.php @@ -4,18 +4,16 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\MicropubToken; use App\Services\TokenService; -use DateTimeImmutable; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; class TokenServiceTest extends TestCase { /** - * Given the token is dependent on a random nonce, the time of creation and - * the APP_KEY, to test, we shall create a token, and then verify it. + * Given the token is dependent on a random value and stored only as a + * hash, to test, we shall create a token, and then verify it. */ #[Test] public function tokenservice_creates_valid_tokens(): void @@ -41,24 +39,29 @@ class TokenServiceTest extends TestCase } #[Test] - public function tokens_with_different_signing_key_are_not_valid(): void + public function unknown_tokens_are_not_valid(): void { + $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.bin2hex(random_bytes(32))]); + + $response->assertJson([ + 'response' => 'error', + 'error' => 'invalid_token', + 'error_description' => 'The provided token did not pass validation', + ]); + } + + #[Test] + public function revoked_tokens_are_not_valid(): void + { + $tokenService = new TokenService; $data = [ 'me' => 'https://example.org', 'client_id' => 'https://quill.p3k.io', 'scope' => 'post', ]; + $token = $tokenService->getNewToken($data); - $config = resolve(Configuration::class); - - $token = $config->builder() - ->issuedAt(new DateTimeImmutable) - ->withClaim('client_id', $data['client_id']) - ->withClaim('me', $data['me']) - ->withClaim('scope', $data['scope']) - ->withClaim('nonce', bin2hex(random_bytes(8))) - ->getToken($config->signer(), InMemory::plainText(random_bytes(32))) - ->toString(); + MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke(); $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]); @@ -68,4 +71,18 @@ class TokenServiceTest extends TestCase 'error_description' => 'The provided token did not pass validation', ]); } + + /** + * Request input for a "string" field can be sent as an array + * (e.g. token[]=a&token[]=b). Casting that to string throws in this app + * (warnings are promoted to exceptions), so findActive() must guard + * against it rather than assume its caller already validated the type. + */ + #[Test] + public function find_active_treats_non_string_input_as_absent(): void + { + $this->assertNull(MicropubToken::findActive(['a', 'b'])); + $this->assertNull(MicropubToken::findActive(null)); + $this->assertNull(MicropubToken::findActive(123)); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index cade29d9..c3307f1f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,11 +3,19 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Illuminate\Support\Facades\Http; abstract class TestCase extends BaseTestCase { use CreatesApplication; + protected function setUp(): void + { + parent::setUp(); + + Http::preventStrayRequests(); + } + public function removeDirIfEmpty(string $dir): void { // scandir() will always return `.` and `..` so even an “empty” diff --git a/tests/TestToken.php b/tests/TestToken.php index 287e2757..21e0b753 100644 --- a/tests/TestToken.php +++ b/tests/TestToken.php @@ -2,53 +2,39 @@ namespace Tests; -use DateTimeImmutable; -use Lcobucci\JWT\Configuration; +use App\Services\TokenService; trait TestToken { public function getToken(): string { - $config = $this->app->make(Configuration::class); - - return $config->builder() - ->issuedAt(new DateTimeImmutable) - ->withClaim('client_id', 'https://quill.p3k.io') - ->withClaim('me', 'http://jonnybarnes.localhost') - ->withClaim('scope', ['create', 'update']) - ->getToken($config->signer(), $config->signingKey()) - ->toString(); + return $this->app->make(TokenService::class)->getNewToken([ + 'client_id' => 'https://quill.p3k.io', + 'me' => 'http://jonnybarnes.localhost', + 'scope' => 'create update', + ]); } public function getTokenWithIncorrectScope(): string { - $config = $this->app->make(Configuration::class); - - return $config->builder() - ->issuedAt(new DateTimeImmutable) - ->withClaim('client_id', 'https://quill.p3k.io') - ->withClaim('me', 'https://jonnybarnes.localhost') - ->withClaim('scope', 'view') - ->getToken($config->signer(), $config->signingKey()) - ->toString(); + return $this->app->make(TokenService::class)->getNewToken([ + 'client_id' => 'https://quill.p3k.io', + 'me' => 'https://jonnybarnes.localhost', + 'scope' => 'view', + ]); } - public function getTokenWithNoScope() + public function getTokenWithNoScope(): string { - $config = $this->app->make(Configuration::class); - - return $config->builder() - ->issuedAt(new DateTimeImmutable) - ->withClaim('client_id', 'https://quill.p3k.io') - ->withClaim('me', 'https://jonnybarnes.localhost') - ->getToken($config->signer(), $config->signingKey()) - ->toString(); + return $this->app->make(TokenService::class)->getNewToken([ + 'client_id' => 'https://quill.p3k.io', + 'me' => 'https://jonnybarnes.localhost', + 'scope' => '', + ]); } - public function getInvalidToken() + public function getInvalidToken(): string { - $token = $this->getToken(); - - return substr($token, 0, -5); + return bin2hex(random_bytes(32)); } } diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index fda1abf4..afcc0828 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -63,6 +63,28 @@ class ArticlesTest extends TestCase ); } + #[Test] + public function uri_is_the_absolute_form_of_the_link(): void + { + $article = Article::create([ + 'title' => 'Test', + 'main' => 'Test', + ]); + + $this->assertEquals(config('app.url').$article->link, $article->uri); + } + + #[Test] + public function slug_is_suffixed_when_a_trashed_article_already_used_it(): void + { + $original = Article::create(['title' => 'My Title', 'main' => 'Content']); + $original->delete(); + + $newArticle = Article::create(['title' => 'My Title', 'main' => 'Other content']); + + $this->assertEquals('my-title-2', $newArticle->titleurl); + } + #[Test] public function date_scope_returns_expected_articles(): void { diff --git a/tests/Unit/BookmarksTest.php b/tests/Unit/BookmarksTest.php index ce64848c..159e9c0e 100644 --- a/tests/Unit/BookmarksTest.php +++ b/tests/Unit/BookmarksTest.php @@ -6,10 +6,7 @@ namespace Tests\Unit; use App\Exceptions\InternetArchiveException; use App\Services\BookmarkService; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -29,12 +26,9 @@ class BookmarksTest extends TestCase #[Test] public function archive_link_method_calls_archive_service(): void { - $mock = new MockHandler([ - new Response(200, ['Content-Location' => '/web/1234/example.org']), + Http::fake([ + 'web.archive.org/*' => Http::response('', 200, ['Content-Location' => '/web/1234/example.org']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $url = (new BookmarkService)->getArchiveLink('https://example.org'); $this->assertEquals('/web/1234/example.org', $url); } @@ -44,12 +38,9 @@ class BookmarksTest extends TestCase { $this->expectException(InternetArchiveException::class); - $mock = new MockHandler([ - new Response(403), + Http::fake([ + 'web.archive.org/*' => Http::response('', 403), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); (new BookmarkService)->getArchiveLink('https://example.org'); } @@ -58,12 +49,9 @@ class BookmarksTest extends TestCase { $this->expectException(InternetArchiveException::class); - $mock = new MockHandler([ - new Response(200), + Http::fake([ + 'web.archive.org/*' => Http::response('', 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); (new BookmarkService)->getArchiveLink('https://example.org'); } } diff --git a/tests/Unit/Jobs/DownloadWebMentionJobTest.php b/tests/Unit/Jobs/DownloadWebMentionJobTest.php index 6ea6e068..530f1590 100644 --- a/tests/Unit/Jobs/DownloadWebMentionJobTest.php +++ b/tests/Unit/Jobs/DownloadWebMentionJobTest.php @@ -5,11 +5,8 @@ declare(strict_types=1); namespace Tests\Unit\Jobs; use App\Jobs\DownloadWebMention; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; use Illuminate\FileSystem\FileSystem; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -35,19 +32,16 @@ class DownloadWebMentionJobTest extends TestCase HTML; $html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html); - $mock = new MockHandler([ - new Response(200, ['X-Foo' => 'Bar'], $html), - new Response(200, ['X-Foo' => 'Bar'], $html), + Http::fake([ + 'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $job = new DownloadWebMention($source); - $job->handle($client); + $job->handle(); $this->assertFileExists(storage_path('HTML/https')); - $job->handle($client); + $job->handle(); $this->assertFileDoesNotExist(storage_path('HTML/https/example.org/reply').'/1.'.date('Y-m-d').'.backup'); } @@ -70,19 +64,18 @@ class DownloadWebMentionJobTest extends TestCase HTML; $html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html); $html2 = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html2); - $mock = new MockHandler([ - new Response(200, ['X-Foo' => 'Bar'], $html), - new Response(200, ['X-Foo' => 'Bar'], $html2), + Http::fake([ + 'example.org/*' => Http::sequence() + ->push($html, 200, ['X-Foo' => 'Bar']) + ->push($html2, 200, ['X-Foo' => 'Bar']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $job = new DownloadWebMention($source); - $job->handle($client); + $job->handle(); $this->assertFileExists(storage_path('HTML/https')); - $job->handle($client); + $job->handle(); $this->assertFileExists(storage_path('HTML/https/example.org/reply').'/1.'.date('Y-m-d').'.backup'); } @@ -98,14 +91,12 @@ class DownloadWebMentionJobTest extends TestCase HTML; $html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html); - $mock = new MockHandler([ - new Response(200, ['X-Foo' => 'Bar'], $html), + Http::fake([ + 'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $job = new DownloadWebMention($source); - $job->handle($client); + $job->handle(); $this->assertFileExists(storage_path('HTML/https/example.org/reply-one/index.html')); } diff --git a/tests/Unit/Jobs/NotifyBrrrOfWebMentionJobTest.php b/tests/Unit/Jobs/NotifyBrrrOfWebMentionJobTest.php new file mode 100644 index 00000000..f28bc4eb --- /dev/null +++ b/tests/Unit/Jobs/NotifyBrrrOfWebMentionJobTest.php @@ -0,0 +1,71 @@ + 'https://api.brrr.now/v1/br_usr_test']); + Http::fake(); + + $webMention = WebMention::factory()->make([ + 'source' => 'https://example.org/reply/1', + 'target' => 'https://jonnybarnes.uk/notes/1', + 'type' => 'in-reply-to', + ]); + + $job = new NotifyBrrrOfWebMention($webMention); + $job->handle(); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.brrr.now/v1/br_usr_test' + && $request['title'] === 'New reply' + && $request['message'] === 'From https://example.org/reply/1' + && $request['open_url'] === 'https://jonnybarnes.uk/notes/1'; + }); + } + + #[Test] + public function it_titles_notifications_by_webmention_type(): void + { + config(['services.brrr.webhook_url' => 'https://api.brrr.now/v1/br_usr_test']); + Http::fake(); + + foreach ([ + 'in-reply-to' => 'New reply', + 'like-of' => 'New like', + 'repost-of' => 'New repost', + 'something-else' => 'New webmention', + ] as $type => $expectedTitle) { + $webMention = WebMention::factory()->make(['type' => $type]); + + (new NotifyBrrrOfWebMention($webMention))->handle(); + + Http::assertSent(fn (Request $request) => $request['title'] === $expectedTitle); + } + } + + #[Test] + public function it_does_nothing_when_no_webhook_url_is_configured(): void + { + config(['services.brrr.webhook_url' => null]); + Http::fake(); + + $webMention = WebMention::factory()->make(); + + (new NotifyBrrrOfWebMention($webMention))->handle(); + + Http::assertNothingSent(); + } +} diff --git a/tests/Unit/Jobs/ProcessMediaJobTest.php b/tests/Unit/Jobs/ProcessMediaJobTest.php index da9f070c..9ffa2eee 100644 --- a/tests/Unit/Jobs/ProcessMediaJobTest.php +++ b/tests/Unit/Jobs/ProcessMediaJobTest.php @@ -6,7 +6,6 @@ namespace Tests\Unit\Jobs; use App\Jobs\ProcessMedia; use Illuminate\Support\Facades\Storage; -use Intervention\Image\ImageManager; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -15,10 +14,9 @@ class ProcessMediaJobTest extends TestCase #[Test] public function non_media_files_are_not_saved(): void { - $manager = app()->make(ImageManager::class); Storage::disk('local')->put('media/file.txt', 'This is not an image'); - $job = new ProcessMedia('file.txt'); - $job->handle($manager); + $job = new ProcessMedia('media/file.txt'); + $job->handle(); $this->assertFileDoesNotExist(storage_path('app/media/').'file.txt'); } @@ -26,10 +24,9 @@ class ProcessMediaJobTest extends TestCase #[Test] public function small_images_are_not_resized(): void { - $manager = app()->make(ImageManager::class); Storage::disk('local')->put('media/aaron.png', file_get_contents(__DIR__.'/../../aaron.png')); - $job = new ProcessMedia('aaron.png'); - $job->handle($manager); + $job = new ProcessMedia('media/aaron.png'); + $job->handle(); $this->assertFileDoesNotExist(storage_path('app/media/').'aaron.png'); @@ -41,10 +38,9 @@ class ProcessMediaJobTest extends TestCase #[Test] public function large_images_have_smaller_images_created(): void { - $manager = app()->make(ImageManager::class); Storage::disk('local')->put('media/test-image.jpg', file_get_contents(__DIR__.'/../../test-image.jpg')); $job = new ProcessMedia('media/test-image.jpg'); - $job->handle($manager); + $job->handle(); // These need to look in public disk Storage::disk('public')->assertExists('media/test-image.jpg'); diff --git a/tests/Unit/Jobs/ProcessWebMentionJobTest.php b/tests/Unit/Jobs/ProcessWebMentionJobTest.php index 5eb98d9e..f7c9329e 100644 --- a/tests/Unit/Jobs/ProcessWebMentionJobTest.php +++ b/tests/Unit/Jobs/ProcessWebMentionJobTest.php @@ -5,16 +5,14 @@ declare(strict_types=1); namespace Tests\Unit\Jobs; use App\Exceptions\RemoteContentNotFoundException; +use App\Jobs\NotifyBrrrOfWebMention; use App\Jobs\ProcessWebMention; use App\Jobs\SaveProfileImage; use App\Models\Note; use App\Models\WebMention; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; use Illuminate\FileSystem\FileSystem; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Jonnybarnes\WebmentionsParser\Parser; use PHPUnit\Framework\Attributes\Test; @@ -39,17 +37,15 @@ class ProcessWebMentionJobTest extends TestCase $this->expectException(RemoteContentNotFoundException::class); $parser = new Parser; - $mock = new MockHandler([ - new Response(404), + Http::fake([ + '*' => Http::response('', 404), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); $source = 'https://example.org/mention/1/'; $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); } #[Test] @@ -65,19 +61,18 @@ class ProcessWebMentionJobTest extends TestCase HTML; $html = str_replace('href="', 'href="'.config('app.url'), $html); - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); $source = 'https://example.org/mention/1/'; $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); Queue::assertPushed(SaveProfileImage::class); + Queue::assertPushed(NotifyBrrrOfWebMention::class); $this->assertDatabaseHas('webmentions', [ 'source' => $source, 'type' => 'like-of', @@ -103,16 +98,15 @@ class ProcessWebMentionJobTest extends TestCase
Updated reply
HTML; - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); Queue::assertPushed(SaveProfileImage::class); + Queue::assertNotPushed(NotifyBrrrOfWebMention::class); $this->assertDatabaseHas('webmentions', [ 'source' => $source, 'type' => 'in-reply-to', @@ -132,11 +126,9 @@ class ProcessWebMentionJobTest extends TestCase
Replying to someone else
HTML; - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); $source = 'https://example.org/reply/1'; @@ -151,7 +143,7 @@ class ProcessWebMentionJobTest extends TestCase ]); $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); $this->assertDatabaseMissing('webmentions', [ 'source' => $source, @@ -169,11 +161,9 @@ class ProcessWebMentionJobTest extends TestCase
I like someone else now
HTML; - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); $source = 'https://example.org/reply/1'; @@ -188,7 +178,7 @@ class ProcessWebMentionJobTest extends TestCase ]); $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); $this->assertDatabaseMissing('webmentions', [ 'source' => $source, @@ -208,19 +198,18 @@ class ProcessWebMentionJobTest extends TestCase HTML; $html = str_replace('href="', 'href="'.config('app.url'), $html); - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); // Simulate a long brid.gy Bluesky source URL (well over 255 characters) $source = 'https://brid.gy/comment/bluesky/did:plc:n3jhgiq2ykctnpgzlm6p6b25/at%253A%252F%252Fdid%253Aplc%253An3jhgiq2ykctnpgzlm6p6b25%252Fapp.bsky.feed.post%252F3mfekjuhykb2k/at%253A%252F%252Fdid%253Aplc%253Afsfuwigo5juzdwp23hyianzg%252Fapp.bsky.feed.post%252F3mfemw4rrvs2t'; $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); + Queue::assertPushed(NotifyBrrrOfWebMention::class); $this->assertGreaterThan(255, strlen($source)); $this->assertDatabaseHas('webmentions', [ 'source' => $source, @@ -238,11 +227,9 @@ class ProcessWebMentionJobTest extends TestCase
Reposting someone else
HTML; - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); $source = 'https://example.org/reply/1'; @@ -257,7 +244,7 @@ class ProcessWebMentionJobTest extends TestCase ]); $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); $this->assertDatabaseMissing('webmentions', [ 'source' => $source, diff --git a/tests/Unit/Jobs/SaveProfileImageJobTest.php b/tests/Unit/Jobs/SaveProfileImageJobTest.php index f9b92bb3..8e0edbb4 100644 --- a/tests/Unit/Jobs/SaveProfileImageJobTest.php +++ b/tests/Unit/Jobs/SaveProfileImageJobTest.php @@ -5,10 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Jobs; use App\Jobs\SaveProfileImage; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; +use Illuminate\Support\Facades\Http; use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; use PHPUnit\Framework\Attributes\Test; @@ -58,12 +55,9 @@ class SaveProfileImageJobTest extends TestCase #[Test] public function remote_author_images_are_saved_locally(): void { - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'), + Http::fake([ + '*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $mf = ['items' => []]; $author = [ 'properties' => [ @@ -83,12 +77,9 @@ class SaveProfileImageJobTest extends TestCase #[Test] public function local_default_author_image_is_used_as_fallback(): void { - $mock = new MockHandler([ - new Response(404), + Http::fake([ + '*' => Http::response('', 404), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $mf = ['items' => []]; $author = [ 'properties' => [ @@ -111,12 +102,9 @@ class SaveProfileImageJobTest extends TestCase #[Test] public function we_get_url_from_photo_object_if_alt_text_is_provided(): void { - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'), + Http::fake([ + '*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $mf = ['items' => []]; $author = [ 'properties' => [ @@ -139,12 +127,9 @@ class SaveProfileImageJobTest extends TestCase #[Test] public function use_first_url_if_multiple_homepages_are_provided(): void { - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'), + Http::fake([ + '*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $mf = ['items' => []]; $author = [ 'properties' => [ diff --git a/tests/Unit/Jobs/SaveScreenshotJobTest.php b/tests/Unit/Jobs/SaveScreenshotJobTest.php index a407e3d3..f7f68f89 100644 --- a/tests/Unit/Jobs/SaveScreenshotJobTest.php +++ b/tests/Unit/Jobs/SaveScreenshotJobTest.php @@ -6,13 +6,8 @@ namespace Tests\Unit\Jobs; use App\Jobs\SaveScreenshot; use App\Models\Bookmark; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\ConnectException; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; -use GuzzleHttp\Psr7\Response; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -25,57 +20,34 @@ class SaveScreenshotJobTest extends TestCase public function screenshot_is_saved_by_job(): void { Storage::fake('public'); - $guzzleMock = new MockHandler([ - new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'), - new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'), - new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')), + + Http::fake([ + 'api.cloudconvert.com/v2/capture-website' => Http::response([ + 'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished'], + ], 201), + 'api.cloudconvert.com/v2/tasks/68d52633-e170-465e-b13e-746c97d01ffb*' => Http::response([ + 'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished'], + ], 200), + 'api.cloudconvert.com/v2/export/url' => Http::response([ + 'data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'finished'], + ], 201), + 'api.cloudconvert.com/v2/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb*' => Http::response([ + 'data' => [ + 'id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', + 'status' => 'finished', + 'result' => [ + 'files' => [[ + 'url' => 'https://storage.cloudconvert.com/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb/theverge.com.png', + ]], + ], + ], + ], 200), + 'storage.cloudconvert.com/*' => Http::response( + file_get_contents(__DIR__.'/../../theverge.com.png'), + 200, + ['Content-Type' => 'image/png'] + ), ]); - $guzzleHandler = HandlerStack::create($guzzleMock); - $guzzleClient = new Client(['handler' => $guzzleHandler]); - $this->app->instance(Client::class, $guzzleClient); - $retryMock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'), - new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'), - ]); - $retryHandler = HandlerStack::create($retryMock); - $retryHandler->push(Middleware::retry( - function ($retries, $request, $response, $exception) { - // Limit the number of retries to 5 - if ($retries >= 5) { - return false; - } - - // Retry connection exceptions - if ($exception instanceof ConnectException) { - return true; - } - - // Retry on server errors - if ($response && $response->getStatusCode() >= 500) { - return true; - } - - $responseBody = ''; - - if (is_string($response)) { - $responseBody = $response; - } - - if ($response instanceof Response) { - $responseBody = $response->getBody()->getContents(); - $response->getBody()->rewind(); - } - - // Finally for CloudConvert, retry if status is not final - return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished'; - }, - function () { - // Retry after 1 second - return 1000; - } - )); - $retryClient = new Client(['handler' => $retryHandler]); - $this->app->instance('RetryGuzzle', $retryClient); $bookmark = Bookmark::factory()->create(); $job = new SaveScreenshot($bookmark); @@ -84,68 +56,45 @@ class SaveScreenshotJobTest extends TestCase $this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot); Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png'); + + // capture-website, 1x poll (finished immediately), export/url, 1x poll (finished immediately), download + Http::assertSentCount(5); } #[Test] public function screenshot_job_handles_unfinished_tasks(): void { Storage::fake('public'); - $guzzleMock = new MockHandler([ - new Response(201, ['Content-Type' => 'application/json'], '{"id":1,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'), - new Response(201, ['Content-Type' => 'application/json'], '{"id":2,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'), - new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')), + + Http::fake([ + 'api.cloudconvert.com/v2/capture-website' => Http::response([ + 'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'waiting'], + ], 201), + 'api.cloudconvert.com/v2/tasks/68d52633-e170-465e-b13e-746c97d01ffb*' => Http::sequence() + ->push(['data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'waiting']], 200) + ->push(['data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished']], 200), + 'api.cloudconvert.com/v2/export/url' => Http::response([ + 'data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'waiting'], + ], 201), + 'api.cloudconvert.com/v2/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb*' => Http::sequence() + ->push(['data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'waiting']], 200) + ->push([ + 'data' => [ + 'id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', + 'status' => 'finished', + 'result' => [ + 'files' => [[ + 'url' => 'https://storage.cloudconvert.com/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb/theverge.com.png', + ]], + ], + ], + ], 200), + 'storage.cloudconvert.com/*' => Http::response( + file_get_contents(__DIR__.'/../../theverge.com.png'), + 200, + ['Content-Type' => 'image/png'] + ), ]); - $guzzleHandler = HandlerStack::create($guzzleMock); - $guzzleClient = new Client(['handler' => $guzzleHandler]); - $this->app->instance(Client::class, $guzzleClient); - $container = []; - $history = Middleware::history($container); - $retryMock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], '{"id":3,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":1,"code":null,"message":null,"percent":50,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'), - new Response(200, ['Content-Type' => 'application/json'], '{"id":4,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'), - new Response(200, ['Content-Type' => 'application/json'], '{"id":5,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":0,"code":null,"message":null,"percent":50,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'), - new Response(200, ['Content-Type' => 'application/json'], '{"id":6,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'), - ]); - $retryHandler = HandlerStack::create($retryMock); - $retryHandler->push($history); - $retryHandler->push(Middleware::retry( - function ($retries, $request, $response, $exception) { - // Limit the number of retries to 5 - if ($retries >= 5) { - return false; - } - - // Retry connection exceptions - if ($exception instanceof ConnectException) { - return true; - } - - // Retry on server errors - if ($response && $response->getStatusCode() >= 500) { - return true; - } - - $responseBody = ''; - - if (is_string($response)) { - $responseBody = $response; - } - - if ($response instanceof Response) { - $responseBody = $response->getBody()->getContents(); - $response->getBody()->rewind(); - } - - // Finally for CloudConvert, retry if status is not final - return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished'; - }, - function () { - // Retry after 1 second - return 1000; - } - )); - $retryClient = new Client(['handler' => $retryHandler]); - $this->app->instance('RetryGuzzle', $retryClient); $bookmark = Bookmark::factory()->create(); $job = new SaveScreenshot($bookmark); @@ -154,9 +103,10 @@ class SaveScreenshotJobTest extends TestCase $this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot); Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png'); - // Also assert we made the correct number of requests - $this->assertCount(2, $container); - // However with retries there should be more than 4 responses for the 2 requests - $this->assertEquals(0, $retryMock->count()); + + // capture-website, 2x poll (waiting then finished), export/url, 2x poll (waiting then finished), download + Http::assertSentCount(7); + // Also assert every queued response in each sequence was consumed, no more no less + Http::assertSequencesAreEmpty(); } } diff --git a/tests/Unit/Jobs/SendWebMentionJobTest.php b/tests/Unit/Jobs/SendWebMentionJobTest.php index 29973328..1c26460a 100644 --- a/tests/Unit/Jobs/SendWebMentionJobTest.php +++ b/tests/Unit/Jobs/SendWebMentionJobTest.php @@ -6,10 +6,7 @@ namespace Tests\Unit\Jobs; use App\Jobs\SendWebMentions; use App\Models\Note; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -28,12 +25,9 @@ class SendWebMentionJobTest extends TestCase public function discover_webmention_endpoint_from_header_links(): void { $url = 'https://example.org/webmention'; - $mock = new MockHandler([ - new Response(200, ['Link' => '<'.$url.'>; rel="webmention"']), + Http::fake([ + '*' => Http::response('', 200, ['Link' => '<'.$url.'>; rel="webmention"']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $job = new SendWebMentions(new Note); $this->assertEquals($url, $job->discoverWebmentionEndpoint('https://example.org')); @@ -43,12 +37,9 @@ class SendWebMentionJobTest extends TestCase public function discover_webmention_endpoint_from_html_link_tags(): void { $html = ''; - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $job = new SendWebMentions(new Note); $this->assertEquals( @@ -61,12 +52,9 @@ class SendWebMentionJobTest extends TestCase public function discover_webmention_endpoint_from_legacy_html_markup(): void { $html = ''; - $mock = new MockHandler([ - new Response(200, [], $html), + Http::fake([ + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $job = new SendWebMentions(new Note); $this->assertEquals( @@ -95,13 +83,10 @@ class SendWebMentionJobTest extends TestCase public function we_send_a_webmention_for_a_note(): void { $html = ''; - $mock = new MockHandler([ - new Response(200, [], $html), - new Response(202), + Http::fake([ + 'example.org/webmention' => Http::response('', 202), + '*' => Http::response($html, 200), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $note = new Note; $note->note = 'Hi [Aaron](https://aaronparecki.com)'; @@ -114,13 +99,10 @@ class SendWebMentionJobTest extends TestCase #[Test] public function links_in_notes_can_not_support_webmentions(): void { - $mock = new MockHandler([ + Http::fake([ // URLs with commas currently break the parse function I’m using - new Response(200, ['Link' => '; rel="preconnect"']), + '*' => Http::response('', 200, ['Link' => '; rel="preconnect"']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - $this->app->instance(Client::class, $client); $job = new SendWebMentions(new Note); $this->assertNull($job->discoverWebmentionEndpoint('https://example.org')); diff --git a/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php b/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php index 23ab2dcd..29935011 100644 --- a/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php +++ b/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php @@ -5,12 +5,9 @@ namespace Tests\Unit\Jobs; use App\Jobs\SyndicateNoteToBluesky; use App\Models\Note; use Faker\Factory; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; -use GuzzleHttp\Psr7\Response; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -24,19 +21,17 @@ class SyndicateNoteToBlueskyJobTest extends TestCase $faker = Factory::create(); $randomNumber = $faker->randomNumber(); $blueskyUrl = 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber; - $mock = new MockHandler([ - new Response(201, ['Content-Type' => 'application/json'], json_encode([ + Http::fake([ + 'brid.gy/*' => Http::response([ 'url' => $blueskyUrl, 'id' => (string) $randomNumber, 'type' => ['h-entry'], - ])), + ], 201), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); $job = new SyndicateNoteToBluesky($note); - $job->handle($client); + $job->handle(); $this->assertDatabaseHas('notes', [ 'bluesky_url' => $blueskyUrl, @@ -46,39 +41,31 @@ class SyndicateNoteToBlueskyJobTest extends TestCase #[Test] public function we_post_the_correct_source_and_target(): void { - $container = []; - $history = Middleware::history($container); - $mock = new MockHandler([ - new Response(201, ['Content-Type' => 'application/json'], json_encode([ + Http::fake([ + 'brid.gy/*' => Http::response([ 'url' => 'https://bsky.app/profile/jonnybarnes.uk/1', - ])), + ], 201), ]); - $handler = HandlerStack::create($mock); - $handler->push($history); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(['note' => 'This is a **test**']); $job = new SyndicateNoteToBluesky($note); - $job->handle($client); + $job->handle(); - $request = $container[0]['request']; - $body = []; - parse_str((string) $request->getBody(), $body); - - $this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri()); - $this->assertSame($note->uri, $body['source']); - $this->assertSame('https://brid.gy/publish/bluesky', $body['target']); + Http::assertSent(function (Request $request) use ($note) { + return $request->url() === 'https://brid.gy/publish/webmention' + && $request['source'] === $note->uri + && $request['target'] === 'https://brid.gy/publish/bluesky'; + }); } #[Test] public function a_bridgy_failure_throws_and_does_not_set_bluesky_url(): void { - $mock = new MockHandler([ - new Response(400, ['Content-Type' => 'application/json'], json_encode([ + Http::fake([ + 'brid.gy/*' => Http::response([ 'error' => 'Could not find target link', - ])), + ], 400), ]); - $client = new Client(['handler' => HandlerStack::create($mock)]); $note = Note::factory()->create(); $job = new SyndicateNoteToBluesky($note); @@ -86,7 +73,7 @@ class SyndicateNoteToBlueskyJobTest extends TestCase $this->expectException(\RuntimeException::class); try { - $job->handle($client); + $job->handle(); } finally { $this->assertDatabaseHas('notes', [ 'id' => $note->id, diff --git a/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php b/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php index bdc780e6..be0a04c2 100644 --- a/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php +++ b/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php @@ -5,12 +5,9 @@ namespace Tests\Unit\Jobs; use App\Jobs\SyndicateNoteToMastodon; use App\Models\Note; use Faker\Factory; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; -use GuzzleHttp\Psr7\Response; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -24,19 +21,17 @@ class SyndicateNoteToMastodonJobTest extends TestCase $faker = Factory::create(); $randomNumber = $faker->randomNumber(); $mastodonUrl = 'https://mastodon.example/@jonny/'.$randomNumber; - $mock = new MockHandler([ - new Response(201, ['Content-Type' => 'application/json'], json_encode([ + Http::fake([ + 'brid.gy/*' => Http::response([ 'url' => $mastodonUrl, 'id' => (string) $randomNumber, 'type' => ['h-entry'], - ])), + ], 201), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(); $job = new SyndicateNoteToMastodon($note); - $job->handle($client); + $job->handle(); $this->assertDatabaseHas('notes', [ 'mastodon_url' => $mastodonUrl, @@ -46,39 +41,31 @@ class SyndicateNoteToMastodonJobTest extends TestCase #[Test] public function we_post_the_correct_source_and_target(): void { - $container = []; - $history = Middleware::history($container); - $mock = new MockHandler([ - new Response(201, ['Content-Type' => 'application/json'], json_encode([ + Http::fake([ + 'brid.gy/*' => Http::response([ 'url' => 'https://mastodon.example/@jonny/1', - ])), + ], 201), ]); - $handler = HandlerStack::create($mock); - $handler->push($history); - $client = new Client(['handler' => $handler]); $note = Note::factory()->create(['note' => 'This is a **test**']); $job = new SyndicateNoteToMastodon($note); - $job->handle($client); + $job->handle(); - $request = $container[0]['request']; - $body = []; - parse_str((string) $request->getBody(), $body); - - $this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri()); - $this->assertSame($note->uri, $body['source']); - $this->assertSame('https://brid.gy/publish/mastodon', $body['target']); + Http::assertSent(function (Request $request) use ($note) { + return $request->url() === 'https://brid.gy/publish/webmention' + && $request['source'] === $note->uri + && $request['target'] === 'https://brid.gy/publish/mastodon'; + }); } #[Test] public function a_bridgy_failure_throws_and_does_not_set_mastodon_url(): void { - $mock = new MockHandler([ - new Response(400, ['Content-Type' => 'application/json'], json_encode([ + Http::fake([ + 'brid.gy/*' => Http::response([ 'error' => 'Could not find target link', - ])), + ], 400), ]); - $client = new Client(['handler' => HandlerStack::create($mock)]); $note = Note::factory()->create(); $job = new SyndicateNoteToMastodon($note); @@ -86,7 +73,7 @@ class SyndicateNoteToMastodonJobTest extends TestCase $this->expectException(\RuntimeException::class); try { - $job->handle($client); + $job->handle(); } finally { $this->assertDatabaseHas('notes', [ 'id' => $note->id, diff --git a/tests/Unit/NotesTest.php b/tests/Unit/NotesTest.php index 0778ce2a..afd669ab 100644 --- a/tests/Unit/NotesTest.php +++ b/tests/Unit/NotesTest.php @@ -9,13 +9,10 @@ use App\Models\Media; use App\Models\Note; use App\Models\Place; use App\Models\Tag; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -182,13 +179,9 @@ class NotesTest extends TestCase {"place_id":"198791063","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"relation","osm_id":"5208404","lat":"51.50084125","lon":"-0.142990166340849","display_name":"Buckingham Palace, Ambassador's Court, St. James's, Victoria, Westminster, London, Greater London, England, SW1E 6LA, United Kingdom","address":{"attraction":"Buckingham Palace","road":"Ambassador's Court","neighbourhood":"St. James's","suburb":"Victoria","city":"London","state_district":"Greater London","state":"England","postcode":"SW1E 6LA","country":"UK","country_code":"gb"},"boundingbox":["51.4997342","51.5019473","-0.143984","-0.1413002"]} JSON; // phpcs:enable Generic.Files.LineLength.TooLong - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], $json), + Http::fake([ + 'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - - $this->app->instance(Client::class, $client); $note = new Note; $address = $note->reverseGeoCode(51.50084, -0.14264); @@ -207,13 +200,9 @@ class NotesTest extends TestCase {"place_id":"96518506","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"way","osm_id":"94107885","lat":"51.0225764535969","lon":"0.906664040464189","display_name":"Melon Lane, Newchurch, Shepway, Kent, South East, England, TN29 0AS, United Kingdom","address":{"road":"Melon Lane","suburb":"Newchurch","city":"Shepway","county":"Kent","state_district":"South East","state":"England","postcode":"TN29 0AS","country":"UK","country_code":"gb"},"boundingbox":["51.0140377","51.0371494","0.8873312","0.9109506"]} JSON; // phpcs:enable Generic.Files.LineLength.TooLong - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], $json), + Http::fake([ + 'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - - $this->app->instance(Client::class, $client); $note = new Note; $address = $note->reverseGeoCode(51.02, 0.91); @@ -234,13 +223,9 @@ class NotesTest extends TestCase {"place_id":"198561071","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"relation","osm_id":"1839026","lat":"53.46600455","lon":"-2.23300880782987","display_name":"University of Manchester - Main Campus, Brunswick Street, Curry Mile, Ardwick, Manchester, Greater Manchester, North West England, England, M13 9NR, United Kingdom","address":{"university":"University of Manchester - Main Campus","city":"Manchester","county":"Greater Manchester","state_district":"North West England","state":"England","postcode":"M13 9NR","country":"UK","country_code":"gb"},"boundingbox":["53.4598667","53.4716848","-2.2390346","-2.2262754"]} JSON; // phpcs:enable Generic.Files.LineLength.TooLong - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], $json), + Http::fake([ + 'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - - $this->app->instance(Client::class, $client); $note = new Note; $address = $note->reverseGeoCode(53.466277988406, -2.2304474827445); @@ -261,13 +246,9 @@ class NotesTest extends TestCase {"place_id":"98085404","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"way","osm_id":"103703318","lat":"51.0997470194065","lon":"0.609897771085209","display_name":"Biddenden, Ashford, Kent, South East, England, TN27 8ET, United Kingdom","address":{"county":"Kent","state_district":"South East","state":"England","postcode":"TN27 8ET","country":"UK","country_code":"gb"},"boundingbox":["51.0986632","51.104459","0.5954434","0.6167775"]} JSON; // phpcs:enable Generic.Files.LineLength.TooLong - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], $json), + Http::fake([ + 'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - - $this->app->instance(Client::class, $client); $note = new Note; $address = $note->reverseGeoCode(51.1, 0.61); @@ -285,13 +266,9 @@ class NotesTest extends TestCase {"place_id":"120553244","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"way","osm_id":"191508282","lat":"54.3004150140189","lon":"-9.39993720828084","display_name":"R314, Doonfeeny Lower, Ballycastle ED, Ballina, County Mayo, Connacht, Ireland","address":{"country":"Ireland","country_code":"ie"},"boundingbox":["54.2964027","54.3045856","-9.4337961","-9.3960403"]} JSON; // phpcs:enable Generic.Files.LineLength.TooLong - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], $json), + Http::fake([ + 'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']), ]); - $handler = HandlerStack::create($mock); - $client = new Client(['handler' => $handler]); - - $this->app->instance(Client::class, $client); $note = new Note; $address = $note->reverseGeoCode(54.3, 9.4);