From e08763c5263a607def90a5eb4539da0204fff429 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 26 Jul 2026 09:12:01 +0100 Subject: [PATCH 01/25] Migrate outbound HTTP calls from Guzzle to the Http facade Replaces every raw GuzzleHttp\Client usage (Nominatim, web.archive.org, webmention fetch/discover/send, Bridgy syndication, IndieAuth client_id lookup, contact avatar/h-card fetch, profile image download, and the CloudConvert screenshot pipeline) with Illuminate\Support\Facades\Http, and enables Http::preventStrayRequests() globally in tests so any un-faked outbound call now fails loudly instead of silently hitting the network. The CloudConvert retry-until-finished middleware in AppServiceProvider is replaced by a plain polling loop in SaveScreenshot, which also fixes a latent bug where the old middleware decoded a response object instead of its body. Bridgy syndication jobs keep their tries=1/no-retry semantics unchanged to avoid duplicate publishes. Guzzle's PSR-7 helpers (Header, UriResolver, Utils) stay in SendWebMentions since Http has no equivalent for them. Every affected test file's Guzzle MockHandler/HandlerStack boilerplate is replaced with Http::fake()/Http::sequence(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01P8j7cJhCiYDsGUgB7obKNQ --- .../Controllers/Admin/ContactsController.php | 17 +- app/Http/Controllers/IndieAuthController.php | 11 +- app/Jobs/DownloadWebMention.php | 18 +- app/Jobs/ProcessLike.php | 11 +- app/Jobs/ProcessWebMention.php | 19 +- app/Jobs/SaveProfileImage.php | 13 +- app/Jobs/SaveScreenshot.php | 94 +++++---- app/Jobs/SendWebMentions.php | 23 +-- app/Jobs/SyndicateNoteToBluesky.php | 32 +--- app/Jobs/SyndicateNoteToMastodon.php | 32 +--- app/Models/Note.php | 13 +- app/Providers/AppServiceProvider.php | 36 ---- app/Services/BookmarkService.php | 20 +- tests/Feature/Admin/ContactsTest.php | 34 ++-- tests/Feature/IndieAuthTest.php | 19 +- tests/Feature/LikesTest.php | 41 +--- tests/TestCase.php | 8 + tests/Unit/BookmarksTest.php | 26 +-- tests/Unit/Jobs/DownloadWebMentionJobTest.php | 37 ++-- tests/Unit/Jobs/ProcessWebMentionJobTest.php | 61 +++--- tests/Unit/Jobs/SaveProfileImageJobTest.php | 33 +--- tests/Unit/Jobs/SaveScreenshotJobTest.php | 180 +++++++----------- tests/Unit/Jobs/SendWebMentionJobTest.php | 42 ++-- .../Jobs/SyndicateNoteToBlueskyJobTest.php | 51 ++--- .../Jobs/SyndicateNoteToMastodonJobTest.php | 51 ++--- tests/Unit/NotesTest.php | 45 ++--- 26 files changed, 337 insertions(+), 630 deletions(-) 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/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index 45b488da..eeb59770 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -5,13 +5,12 @@ declare(strict_types=1); namespace App\Http\Controllers; 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; @@ -199,15 +198,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/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/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/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index 6677b285..6cb276f8 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 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/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/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ba42853e..d1e28bcf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,6 @@ namespace App\Providers; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\ConnectException; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; use Illuminate\Database\Eloquent\Model; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -82,38 +78,6 @@ 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()); } 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/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/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index 534fe452..b32f4420 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -5,14 +5,11 @@ declare(strict_types=1); namespace Tests\Feature; use App\Models\User; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; 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; @@ -269,12 +266,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 +307,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', [ 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/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/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/ProcessWebMentionJobTest.php b/tests/Unit/Jobs/ProcessWebMentionJobTest.php index 5eb98d9e..001d8f3d 100644 --- a/tests/Unit/Jobs/ProcessWebMentionJobTest.php +++ b/tests/Unit/Jobs/ProcessWebMentionJobTest.php @@ -9,12 +9,9 @@ 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 +36,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,17 +60,15 @@ 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); $this->assertDatabaseHas('webmentions', [ @@ -103,14 +96,12 @@ 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); $this->assertDatabaseHas('webmentions', [ @@ -132,11 +123,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 +140,7 @@ class ProcessWebMentionJobTest extends TestCase ]); $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); $this->assertDatabaseMissing('webmentions', [ 'source' => $source, @@ -169,11 +158,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 +175,7 @@ class ProcessWebMentionJobTest extends TestCase ]); $job = new ProcessWebMention($note, $source); - $job->handle($parser, $client); + $job->handle($parser); $this->assertDatabaseMissing('webmentions', [ 'source' => $source, @@ -208,18 +195,16 @@ 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); $this->assertGreaterThan(255, strlen($source)); $this->assertDatabaseHas('webmentions', [ @@ -238,11 +223,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 +240,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); From 2eab5749ea8b2f84a689e2a19a4d6a63c74b0b1a Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 26 Jul 2026 10:57:20 +0100 Subject: [PATCH 02/25] Update dependencies --- composer.json | 7 +- composer.lock | 1238 ++++++++++++++++++++------------- config/flare.php | 159 ++--- package-lock.json | 314 ++++----- public/assets/css/app.css.map | 2 +- 5 files changed, 972 insertions(+), 748 deletions(-) diff --git a/composer.json b/composer.json index 1b94abe8..7e55bbd3 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,6 @@ "ext-pgsql": "*", "ext-sodium": "*", "cviebrock/eloquent-sluggable": "^13.0", - "guzzlehttp/guzzle": "^7.2", "indieauth/client": "^1.1", "intervention/image": "^3", "jonnybarnes/indieweb": "~0.2", @@ -27,7 +26,7 @@ "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 +41,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..be951f93 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": "01ba04ab77c167a38ed826d7193ba5ef", "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", @@ -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", @@ -2503,16 +2506,16 @@ }, { "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 +2537,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 +2609,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-07-12T15:29:16+00:00" }, { "name": "league/config", @@ -2692,16 +2695,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 +2772,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 +2827,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 +2882,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 +2901,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 +2922,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 +2934,7 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/uri", @@ -3282,16 +3285,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 +3345,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 +3452,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T13:49:15+00:00" + "time": "2026-07-09T18:23:49+00:00" }, { "name": "nette/schema", @@ -3520,16 +3523,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 +3552,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 +3608,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 +3665,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 +4120,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 +4161,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 +4918,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 +4950,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 +4981,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 +4989,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 +5132,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 +5140,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 +5226,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 +5291,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 +5346,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 +5358,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 +5455,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 +5467,7 @@ "type": "patreon" } ], - "time": "2026-03-23T22:56:56+00:00" + "time": "2026-07-16T10:28:45+00:00" }, { "name": "symfony/clock", @@ -8638,16 +8635,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 +8703,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 +8715,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 +8793,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 +8848,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 +8860,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 +9018,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 +9035,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 +9102,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 +9114,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 +9817,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 +9876,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 +10023,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 +10047,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 +10115,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 +10364,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 +10415,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.8-dev" } }, "autoload": { @@ -10452,7 +10450,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 +10462,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 +10726,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 +10763,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "14.2.x-dev" } }, "autoload": { @@ -10792,7 +10792,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 +10812,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 +10865,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 +10885,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 +10914,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10941,40 +10941,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 +11013,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 +11085,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 +11157,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "13.2-dev" } }, "autoload": { @@ -11151,7 +11189,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 +11197,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 +11246,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 +11266,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 +11298,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.1-dev" + "dev-main": "8.3-dev" } }, "autoload": { @@ -11300,7 +11338,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 +11358,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 +11408,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 +11487,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 +11535,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -11501,7 +11563,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 +11583,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 +11653,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 +11673,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 +11865,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 +11885,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 +11935,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 +11955,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 +12005,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 +12073,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 +12149,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 +12169,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 +12218,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 +12238,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 +12284,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..bb9cbd4e 100644 --- a/config/flare.php +++ b/config/flare.php @@ -1,11 +1,5 @@ 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 @@ -42,27 +25,11 @@ return [ | */ - 'collects' => FlareConfig::defaultCollects( + 'collects' => \Spatie\LaravelFlare\FlareConfig::defaultCollects( ignore: [], 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 +55,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' => \Spatie\LaravelFlare\Senders\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 +106,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 +140,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 +150,7 @@ return [ | */ - 'trace' => env('FLARE_TRACE', false), + 'trace' => env('FLARE_TRACE', true), /* |-------------------------------------------------------------------------- @@ -183,26 +163,35 @@ return [ | which means that 10% of the traces will be recorded. | */ + 'sampler' => [ - 'class' => RateSampler::class, + 'class' => \Spatie\FlareClient\Sampling\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/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.map b/public/assets/css/app.css.map index b817d58d..ca57ea2f 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,YGAE,oaAwBA,kEIxBA,0BAIA,oBAIA,iIAME,0BAIA,uBAIA,iDAIE,yEAME,uBAON,oDPvCF,kBO+CE,SACE,oBLhDF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CGrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA","sources":["resources/css/app.css","resources/css/reset.css","resources/css/header.css","resources/css/notes.css","resources/css/colours.css","resources/css/layout.css","resources/css/pagination.css","resources/css/theme-selector.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 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 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 .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","@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: 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 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 #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"],"names":[]} \ No newline at end of file From 287520ad7bb2b88b2237c25492f1f82d64431845 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Tue, 28 Jul 2026 19:20:18 +0100 Subject: [PATCH 03/25] Force HTTPS URL generation in production Uses Laravel's built-in URL::forceHttps() so route(), url(), and asset() always generate https:// links in production, without affecting local dev. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RP5TeZbk9KS754xuJMobjE --- app/Providers/AppServiceProvider.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d1e28bcf..7718d7ec 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use Illuminate\Database\Eloquent\Model; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Intervention\Image\ImageManager; use Lcobucci\JWT\Configuration; @@ -80,5 +81,8 @@ class AppServiceProvider extends ServiceProvider // Turn on Eloquent strict mode when developing Model::shouldBeStrict(! $this->app->isProduction()); + + // Force HTTPS URL generation in production + URL::forceHttps($this->app->isProduction()); } } From 31c49ac3fc7c9576f6cd51ed07da85d7e25e0140 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 1 Aug 2026 17:07:38 +0100 Subject: [PATCH 04/25] Adopt Laravel's Image facade for media processing, upgrade Intervention to v4 Laravel 13's Image facade wraps Intervention Image v4 internally, so switching our upload width probe and resize job/command to it required bumping intervention/image ^3 -> ^4 (and its intervention/gif ^5 dependency). Removes our own ImageManager container binding and config/image.php in favour of Laravel's built-in driver resolution. Also fixes a latent filename mismatch in ProcessMediaJobTest that Pint's stricter typing on the new Image API turned into a hard TypeError, and tidies config/flare.php to use imported class names instead of FQCNs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018Sorsgn85nw7uQyRMNvzyD --- app/Console/Commands/ReprocessMediaImages.php | 19 ++++----- .../Controllers/MicropubMediaController.php | 10 ++--- app/Jobs/ProcessMedia.php | 20 +++++----- app/Providers/AppServiceProvider.php | 6 --- composer.json | 2 +- composer.lock | 40 +++++++++---------- config/flare.php | 10 +++-- config/image.php | 22 ---------- tests/Unit/Jobs/ProcessMediaJobTest.php | 14 +++---- 9 files changed, 54 insertions(+), 89 deletions(-) delete mode 100644 config/image.php 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/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index 1cca74b3..da7c7dc2 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 @@ -111,12 +112,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/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/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7718d7ec..224472d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,7 +7,6 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; -use Intervention\Image\ImageManager; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key\InMemory; @@ -30,11 +29,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. * diff --git a/composer.json b/composer.json index 7e55bbd3..520d12e1 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "ext-sodium": "*", "cviebrock/eloquent-sluggable": "^13.0", "indieauth/client": "^1.1", - "intervention/image": "^3", + "intervention/image": "^4.0", "jonnybarnes/indieweb": "~0.2", "jonnybarnes/webmentions-parser": "~0.5", "laravel/framework": "^13.0", diff --git a/composer.lock b/composer.lock index be951f93..6af58a17 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": "01ba04ab77c167a38ed826d7193ba5ef", + "content-hash": "23983a4e6a8e79cb9636fe8f0e604eb7", "packages": [ { "name": "aws/aws-crt-php", @@ -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", diff --git a/config/flare.php b/config/flare.php index bb9cbd4e..0b8c6187 100644 --- a/config/flare.php +++ b/config/flare.php @@ -1,5 +1,9 @@ \Spatie\LaravelFlare\FlareConfig::defaultCollects( + 'collects' => FlareConfig::defaultCollects( ignore: [], extra: [] ), @@ -74,7 +78,7 @@ return [ */ 'sender' => [ - 'class' => \Spatie\LaravelFlare\Senders\LaravelHttpSender::class, + 'class' => LaravelHttpSender::class, 'config' => [ 'timeout' => 10, ], @@ -165,7 +169,7 @@ return [ */ 'sampler' => [ - 'class' => \Spatie\FlareClient\Sampling\RateSampler::class, + 'class' => RateSampler::class, 'config' => [ 'rate' => env('FLARE_SAMPLER_RATE', 0.1), ], 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/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'); From f9f2744fad1fdf5ab4d8f98bbbd7ac186d114642 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 2 Aug 2026 18:07:39 +0100 Subject: [PATCH 05/25] Send Brrr push notifications for new webmentions Dispatches a queued job to POST to a configured Brrr webhook whenever a brand-new webmention is saved, so replies/likes/reposts show up as push notifications instead of requiring a manual check of the site. --- .env.example | 2 + app/Jobs/NotifyBrrrOfWebMention.php | 56 +++++++++++++++ app/Jobs/ProcessWebMention.php | 1 + config/services.php | 4 ++ .../Jobs/NotifyBrrrOfWebMentionJobTest.php | 71 +++++++++++++++++++ tests/Unit/Jobs/ProcessWebMentionJobTest.php | 4 ++ 6 files changed, 138 insertions(+) create mode 100644 app/Jobs/NotifyBrrrOfWebMention.php create mode 100644 tests/Unit/Jobs/NotifyBrrrOfWebMentionJobTest.php 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/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/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index 6cb276f8..4ac6f5fd 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -96,6 +96,7 @@ class ProcessWebMention implements ShouldQueue $webmention->type = $type; $webmention->mf2 = json_encode($microformats); $webmention->save(); + dispatch(new NotifyBrrrOfWebMention($webmention)); } /** 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/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/ProcessWebMentionJobTest.php b/tests/Unit/Jobs/ProcessWebMentionJobTest.php index 001d8f3d..f7c9329e 100644 --- a/tests/Unit/Jobs/ProcessWebMentionJobTest.php +++ b/tests/Unit/Jobs/ProcessWebMentionJobTest.php @@ -5,6 +5,7 @@ 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; @@ -71,6 +72,7 @@ class ProcessWebMentionJobTest extends TestCase $job->handle($parser); Queue::assertPushed(SaveProfileImage::class); + Queue::assertPushed(NotifyBrrrOfWebMention::class); $this->assertDatabaseHas('webmentions', [ 'source' => $source, 'type' => 'like-of', @@ -104,6 +106,7 @@ class ProcessWebMentionJobTest extends TestCase $job->handle($parser); Queue::assertPushed(SaveProfileImage::class); + Queue::assertNotPushed(NotifyBrrrOfWebMention::class); $this->assertDatabaseHas('webmentions', [ 'source' => $source, 'type' => 'in-reply-to', @@ -206,6 +209,7 @@ class ProcessWebMentionJobTest extends TestCase $job = new ProcessWebMention($note, $source); $job->handle($parser); + Queue::assertPushed(NotifyBrrrOfWebMention::class); $this->assertGreaterThan(255, strlen($source)); $this->assertDatabaseHas('webmentions', [ 'source' => $source, From d5706b5f8f049c05cb216a460196e32cfc5b2e90 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 13 Aug 2026 16:07:07 +0100 Subject: [PATCH 06/25] Replace JWT Micropub tokens with revocable opaque tokens Tokens now store a hashed row in micropub_tokens instead of being self-contained signed JWTs, so a leaked or unwanted token can actually be revoked. Since revocation already requires a DB lookup on every request, JWT's stateless-verification benefit was gone anyway, so this also drops the lcobucci/jwt dependency entirely. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- app/Http/Controllers/MicropubController.php | 3 +- app/Http/Middleware/VerifyMicropubToken.php | 38 +++------- app/Models/MicropubToken.php | 34 +++++++++ app/Providers/AppServiceProvider.php | 15 ---- app/Services/TokenService.php | 22 +++--- composer.json | 1 - composer.lock | 75 +------------------ ...13_120924_create_micropub_tokens_table.php | 30 ++++++++ tests/Feature/TokenServiceTest.php | 35 +++++---- tests/TestToken.php | 52 +++++-------- 10 files changed, 124 insertions(+), 181 deletions(-) create mode 100644 app/Models/MicropubToken.php create mode 100644 database/migrations/2026_08_13_120924_create_micropub_tokens_table.php diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index c6008a9c..2df5d432 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 { @@ -135,7 +134,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/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index 33d2cb12..e61cc67a 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,17 @@ class VerifyMicropubToken ], 401); } - try { - $tokenData = $this->validateToken($rawToken); - } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { + $token = MicropubToken::where('token_hash', hash('sha256', $rawToken)) + ->whereNull('revoked_at') + ->first(); + + 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 +54,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/Models/MicropubToken.php b/app/Models/MicropubToken.php new file mode 100644 index 00000000..231237f6 --- /dev/null +++ b/app/Models/MicropubToken.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + } + + public function revoke(): void + { + $this->forceFill(['revoked_at' => now()])->save(); + } + + protected function isRevoked(): Attribute + { + return Attribute::make( + get: fn () => $this->revoked_at !== null, + ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 224472d1..68367a97 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,10 +7,6 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; -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; @@ -53,17 +49,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( 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/composer.json b/composer.json index 520d12e1..5871ee02 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,6 @@ "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", diff --git a/composer.lock b/composer.lock index 6af58a17..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": "23983a4e6a8e79cb9636fe8f0e604eb7", + "content-hash": "a2842cf95580a08ad759a92d74fae3b4", "packages": [ { "name": "aws/aws-crt-php", @@ -2431,79 +2431,6 @@ }, "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.3", 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..cb37f28b --- /dev/null +++ b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('token_hash')->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/tests/Feature/TokenServiceTest.php b/tests/Feature/TokenServiceTest.php index 7fe9e854..6643452d 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]); 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)); } } From 9c9a6392c8220fd44fdee4de6a616fd12c7bf57b Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 13 Aug 2026 16:13:11 +0100 Subject: [PATCH 07/25] Add IndieAuth token revocation endpoint (RFC 7009) Implements the current IndieAuth spec's dedicated /revocation endpoint so clients can self-revoke a token (e.g. on user sign-out), rather than only supporting revocation via the admin side. Always responds 200 per spec, whether the token was found or not, so callers can't use it to probe token validity. Skips the legacy action=revoke-on-/token fallback the spec mentions for older clients, since the only real client here is already being updated to use the current endpoint. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- app/Http/Controllers/IndieAuthController.php | 22 +++++++++++++++ app/Http/Middleware/LinkHeadersMiddleware.php | 1 + routes/web.php | 1 + tests/Feature/HeaderLinkTest.php | 5 ++-- tests/Feature/IndieAuthTest.php | 27 +++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index eeb59770..db62aa98 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Models\MicropubToken; use App\Services\TokenService; use GuzzleHttp\Psr7\Uri; use Illuminate\Http\JsonResponse; @@ -24,6 +25,7 @@ class IndieAuthController extends Controller 'issuer' => config('app.url'), 'authorization_endpoint' => route('indieauth.start'), 'token_endpoint' => route('indieauth.token'), + 'revocation_endpoint' => route('indieauth.revocation'), 'code_challenge_methods_supported' => ['S256'], // 'introspection_endpoint' => route('indieauth.introspection'), // 'introspection_endpoint_auth_methods_supported' => ['none'], @@ -178,6 +180,26 @@ 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 + { + $token = $request->get('token', ''); + + if ($token !== '') { + MicropubToken::where('token_hash', hash('sha256', $token)) + ->whereNull('revoked_at') + ->first() + ?->revoke(); + } + + return response()->json([], 200); + } + protected function isValidRedirectUri(string $clientId, string $redirectUri): bool { // If client_id is not a valid URL, then it's not valid diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index b9e55139..0a280d44 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -17,6 +17,7 @@ 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('micropub-endpoint').'>; rel="micropub"', false); $response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false); diff --git a/routes/web.php b/routes/web.php index 953b51ac..fbba6329 100644 --- a/routes/web.php +++ b/routes/web.php @@ -205,6 +205,7 @@ 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'); // Micropub Endpoints Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); diff --git a/tests/Feature/HeaderLinkTest.php b/tests/Feature/HeaderLinkTest.php index 874731a5..8a68d88f 100644 --- a/tests/Feature/HeaderLinkTest.php +++ b/tests/Feature/HeaderLinkTest.php @@ -19,7 +19,8 @@ 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').'/api/post>; rel="micropub"', $linkHeaders[4]); + $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[5]); } } diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index b32f4420..c7420e6d 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\MicropubToken; use App\Models\User; +use App\Services\TokenService; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\UriResolver; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -692,4 +694,29 @@ 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); + } } From 9d6cf6c815e5fd1f4eb0e41c7dff0c89e04cf47f Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 13 Aug 2026 16:18:52 +0100 Subject: [PATCH 08/25] Add admin page to list and revoke Micropub tokens Gives a way to actually use the revocation capability built up over the last few commits from the admin side, not just self-service via the client. Lists client_id/scope/issue time per token (never the raw token itself, since only its hash is stored) with a revoke button for active ones. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- .../Controllers/Admin/TokensController.php | 33 +++++++ resources/views/admin/tokens/index.blade.php | 27 ++++++ resources/views/admin/welcome.blade.php | 5 ++ routes/web.php | 7 ++ tests/Feature/Admin/TokensTest.php | 87 +++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 app/Http/Controllers/Admin/TokensController.php create mode 100644 resources/views/admin/tokens/index.blade.php create mode 100644 tests/Feature/Admin/TokensTest.php diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php new file mode 100644 index 00000000..1b5348f9 --- /dev/null +++ b/app/Http/Controllers/Admin/TokensController.php @@ -0,0 +1,33 @@ +get(); + + return view('admin.tokens.index', compact('tokens')); + } + + /** + * Revoke a Micropub token. + */ + public function revoke(MicropubToken $token): RedirectResponse + { + $token->revoke(); + + return redirect('/admin/tokens'); + } +} diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php new file mode 100644 index 00000000..cb7edda9 --- /dev/null +++ b/resources/views/admin/tokens/index.blade.php @@ -0,0 +1,27 @@ +@extends('master') + +@section('title')List Tokens « Admin CP « @stop + +@section('content') +

Micropub Tokens

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

No tokens have been issued.

+ @else +
    + @foreach($tokens as $token) +
  • + {{ $token->client_id }} — scope: {{ $token->scope }} — issued {{ $token->created_at->diffForHumans() }} + @if($token->isRevoked) + — revoked {{ $token->revoked_at->diffForHumans() }} + @else +
    + {{ csrf_field() }} + {{ method_field('PUT') }} + +
    + @endif +
  • + @endforeach +
+ @endif +@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 269ccdc5..663cfdc4 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -47,6 +47,11 @@ or edit them.

+

Tokens

+

+ View and revoke issued Micropub tokens. +

+

Bio

Edit your bio. diff --git a/routes/web.php b/routes/web.php index fbba6329..e8924f32 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Admin\NotesController as AdminNotesController; use App\Http\Controllers\Admin\PasskeysController; use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; use App\Http\Controllers\Admin\SyndicationTargetsController; +use App\Http\Controllers\Admin\TokensController; use App\Http\Controllers\ArticlesController; use App\Http\Controllers\AuthController; use App\Http\Controllers\BookmarksController; @@ -147,6 +148,12 @@ 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::put('/{token}/revoke', [TokensController::class, 'revoke']); + }); + // Bio Route::prefix('bio')->group(function () { Route::get('/', [BioController::class, 'show'])->name('admin.bio.show'); diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php new file mode 100644 index 00000000..0c415296 --- /dev/null +++ b/tests/Feature/Admin/TokensTest.php @@ -0,0 +1,87 @@ +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 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'); + } +} From 24da24a677d49c8b189ccf340251ab79ddf2da44 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 13 Aug 2026 16:44:31 +0100 Subject: [PATCH 09/25] Add IndieAuth token introspection endpoint (RFC 7662) Lets a resource server (or a client checking its own token, via self-introspection) verify a token's active/me/client_id/scope without needing to be tightly coupled to this token endpoint. Requires the caller to present their own currently-active token as authorization, per spec's requirement that the endpoint MUST require some form of authorization. Inactive tokens get back only {"active": false}, no detail on why, matching the privacy stance already used for revocation. Pulled the hash-and-lookup-active-token logic (now needed a third time) into MicropubToken::findActive(), used by this, the revocation endpoint, and VerifyMicropubToken. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- app/Http/Controllers/IndieAuthController.php | 44 +++++++++--- app/Http/Middleware/VerifyMicropubToken.php | 4 +- app/Models/MicropubToken.php | 14 ++++ routes/web.php | 1 + tests/Feature/IndieAuthTest.php | 76 ++++++++++++++++++++ 5 files changed, 126 insertions(+), 13 deletions(-) diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index db62aa98..bff8ebee 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -26,9 +26,9 @@ class IndieAuthController extends Controller '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'], ]); } @@ -188,18 +188,42 @@ class IndieAuthController extends Controller */ public function processRevocationRequest(Request $request): JsonResponse { - $token = $request->get('token', ''); - - if ($token !== '') { - MicropubToken::where('token_hash', hash('sha256', $token)) - ->whereNull('revoked_at') - ->first() - ?->revoke(); - } + 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((string) $request->bearerToken())) { + return response()->json([], 401); + } + + $token = MicropubToken::findActive((string) $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 diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index e61cc67a..530995ae 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -35,9 +35,7 @@ class VerifyMicropubToken ], 401); } - $token = MicropubToken::where('token_hash', hash('sha256', $rawToken)) - ->whereNull('revoked_at') - ->first(); + $token = MicropubToken::findActive($rawToken); if (! $token) { $micropubResponses = new MicropubResponses; diff --git a/app/Models/MicropubToken.php b/app/Models/MicropubToken.php index 231237f6..e85cb206 100644 --- a/app/Models/MicropubToken.php +++ b/app/Models/MicropubToken.php @@ -25,6 +25,20 @@ class MicropubToken extends Model $this->forceFill(['revoked_at' => now()])->save(); } + /** + * Find the active (non-revoked) token matching a raw bearer token string. + */ + public static function findActive(string $rawToken): ?self + { + if ($rawToken === '') { + return null; + } + + return self::where('token_hash', hash('sha256', $rawToken)) + ->whereNull('revoked_at') + ->first(); + } + protected function isRevoked(): Attribute { return Attribute::make( diff --git a/routes/web.php b/routes/web.php index e8924f32..dd594480 100644 --- a/routes/web.php +++ b/routes/web.php @@ -213,6 +213,7 @@ Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware 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/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index c7420e6d..ba456748 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -719,4 +719,80 @@ class IndieAuthTest extends TestCase $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]); + } } From faf8e5c1ec6e1875fc27c840a87e5a08e14f31bd Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 13 Aug 2026 17:03:43 +0100 Subject: [PATCH 10/25] Fix CSRF exemption and array-input crash on revocation/introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Opus code review of the branch caught two real bugs the test suite structurally couldn't see: - /revocation and /introspect were never added to bootstrap/app.php's CSRF except list, so both were fully broken (403) for any real external client, despite every feature test passing — CSRF verification is short-circuited entirely while running tests. Verified live against the running app before and after the fix, and added a regression test that asserts against the actual configured exemptions rather than relying on request-time behavior that tests can't exercise. - An array-shaped `token` param (e.g. token[]=a&token[]=b) crashed both endpoints with a 500, since this app promotes PHP warnings ("Array to string conversion") to exceptions. Fixed at the shared root, MicropubToken::findActive(), which also closes the same latent hole in VerifyMicropubToken's access_token param that predates this branch. Verified live and covered with regression tests. Also applied the review's lower-severity findings: added the missing introspection_endpoint Link header and metadata test assertions, removed the now-dead is_string($scopes) array branch in the Micropub handlers and media controller (scope is unconditionally a string from the DB now, this guarded against a JWT-array-claim shape that can no longer occur), dropped a redundant #[Table] model attribute, sized token_hash to its actual 64-char length, and removed a one-off inline style in the admin view. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- app/Http/Controllers/IndieAuthController.php | 6 ++-- .../Controllers/MicropubMediaController.php | 8 ++--- app/Http/Middleware/LinkHeadersMiddleware.php | 1 + app/Models/MicropubToken.php | 12 ++++--- .../Micropub/Handlers/CardHandler.php | 4 +-- .../Micropub/Handlers/EntryHandler.php | 4 +-- .../Micropub/Handlers/UpdateHandler.php | 4 +-- bootstrap/app.php | 6 ++-- ...13_120924_create_micropub_tokens_table.php | 2 +- resources/views/admin/tokens/index.blade.php | 2 +- tests/Feature/CsrfExemptionsTest.php | 29 +++++++++++++++++ tests/Feature/HeaderLinkTest.php | 5 +-- tests/Feature/IndieAuthTest.php | 32 +++++++++++++++++-- tests/Feature/TokenServiceTest.php | 14 ++++++++ 14 files changed, 98 insertions(+), 31 deletions(-) create mode 100644 tests/Feature/CsrfExemptionsTest.php diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index bff8ebee..a795bce8 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -188,7 +188,7 @@ class IndieAuthController extends Controller */ public function processRevocationRequest(Request $request): JsonResponse { - MicropubToken::findActive($request->get('token', ''))?->revoke(); + MicropubToken::findActive($request->get('token'))?->revoke(); return response()->json([], 200); } @@ -205,11 +205,11 @@ class IndieAuthController extends Controller */ public function processIntrospectionRequest(Request $request): JsonResponse { - if (! MicropubToken::findActive((string) $request->bearerToken())) { + if (! MicropubToken::findActive($request->bearerToken())) { return response()->json([], 401); } - $token = MicropubToken::findActive((string) $request->get('token', '')); + $token = MicropubToken::findActive($request->get('token')); if (! $token) { return response()->json(['active' => false]); diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index da7c7dc2..d9f8ea32 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -26,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(); } @@ -84,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(); } diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index 0a280d44..e2810f87 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -18,6 +18,7 @@ class LinkHeadersMiddleware $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/Models/MicropubToken.php b/app/Models/MicropubToken.php index e85cb206..c4df41bc 100644 --- a/app/Models/MicropubToken.php +++ b/app/Models/MicropubToken.php @@ -5,11 +5,9 @@ declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; -#[Table('micropub_tokens')] #[Fillable(['token_hash', 'client_id', 'me', 'scope'])] class MicropubToken extends Model { @@ -26,11 +24,15 @@ class MicropubToken extends Model } /** - * Find the active (non-revoked) token matching a raw bearer token string. + * 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(string $rawToken): ?self + public static function findActive(mixed $rawToken): ?self { - if ($rawToken === '') { + if (! is_string($rawToken) || $rawToken === '') { return null; } 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..48bbb550 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; 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/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/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php index cb37f28b..e336912f 100644 --- a/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php +++ b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php @@ -12,7 +12,7 @@ return new class extends Migration { Schema::create('micropub_tokens', function (Blueprint $table) { $table->id(); - $table->string('token_hash')->unique(); + $table->string('token_hash', 64)->unique(); $table->string('client_id'); $table->string('me'); $table->string('scope'); diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index cb7edda9..8836ab07 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -14,7 +14,7 @@ @if($token->isRevoked) — revoked {{ $token->revoked_at->diffForHumans() }} @else -

+ {{ csrf_field() }} {{ method_field('PUT') }} 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/HeaderLinkTest.php b/tests/Feature/HeaderLinkTest.php index 8a68d88f..3983b02c 100644 --- a/tests/Feature/HeaderLinkTest.php +++ b/tests/Feature/HeaderLinkTest.php @@ -20,7 +20,8 @@ class HeaderLinkTest extends TestCase $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').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]); - $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[4]); - $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[5]); + $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 ba456748..08282228 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -29,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'], ]); } @@ -795,4 +796,31 @@ class IndieAuthTest extends TestCase $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/TokenServiceTest.php b/tests/Feature/TokenServiceTest.php index 6643452d..91b9e81d 100644 --- a/tests/Feature/TokenServiceTest.php +++ b/tests/Feature/TokenServiceTest.php @@ -71,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)); + } } From 4ed98c30b2182fc6003c5ff6f16c049db118f0af Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 21 Aug 2026 15:32:09 +0100 Subject: [PATCH 11/25] Fix undefined property error on passkey login Webauthn\PublicKeyCredential has no $id property (only $rawId, inherited from Credential). Every passkey login attempt was throwing Undefined property: Webauthn\PublicKeyCredential::$id and failing with a 500. Encode $rawId the same way it's stored during registration to look up the matching Passkey record. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LHN7V9kyZxrsxaspMSqoGJ --- app/Http/Controllers/Admin/PasskeysController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 30ec2ec0e83977232e2ee4b1ab07123f82780452 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 21 Aug 2026 15:26:01 +0100 Subject: [PATCH 12/25] Initial work on better admin page styling --- public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1233 -> 1477 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 1479 -> 1762 bytes resources/css/admin-tokens.css | 65 +++++++++++++++++++ resources/css/app.css | 1 + resources/views/admin/tokens/index.blade.php | 52 ++++++++++----- 7 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 resources/css/admin-tokens.css diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 4e2ebd32..1d99e7bc 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: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%)}}.token-list{border-collapse:collapse;width:100%;margin-block-start:1em;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.6em .8em}& th{text-transform:uppercase;letter-spacing:.03em;opacity:.7;font-size:.85em}& tr.is-revoked{opacity:.6}& a{word-break:break-all}}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;line-height:1.5;display:inline-block}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145))}.badge-revoked{color:var(--clr-text)}.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))}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index eeec79a35027043c8a76e16f3f60ad4ad6679731..e2d1ff369d415fd1742a121226c0afdf863f2167 100644 GIT binary patch literal 1477 zcmca8Fohw(@WGU`E4|sGmy5&&Z4o%kecyiiQnS~WL+b3j=Ot|CGd)`@`a!VGeYU<< zkawQh+Sq_La9j^j$mS#WZ#Lrqs;*2WH;q3p^V|CPZ;HX)jcH zn3V0CD_;2O-*VZBo?;WYE5BS*Teu}e(sJgPF6~5-H+OuSRz=P|ks!3xtM%}t{alNk zb}KEg?OW%Xu`eZRr+euhx0ip+?l$e0o&LDcwD2v9=UV~iXH)NNh?G6S(feI;!!M05 zmec>|1g_qz+OuPs%SZL3Wz1_^chnzQApgRp`Yp!_*|W~EdWpVs_ejqwSb23`57P?YZL%S!rGB@kggiMJ&=+-J!f}_id9kN6F1(v_$Vr&T zWYGo@!82+nmb4YrsdcScUB?_QcfQzq_qpP7zJQK5rTMcrFJEI$zTMY$nH^X)r97Lx z+{A#VsokUgcx1`?`ha+4hLDZ1Yv)XQ!n+i@K1W*J1u=`x$FOgruDPhBSR)ssiyn&+dYZQKXvPe-kXbS zx6BcJdS$uZ^4C|(w%OGfeJCkc*%`N0?AWcZ_im*>a;Z2F-Xgg9{O?6>>`-g(cbhW>>?euiro@igC%uIt#` zeoH}pgBe98UaiuY*;)8-#)u9TNc^^Mz}t^dR=KzhOQIlFQU z?;c*}sw93o?U>`LZI1uO-`(Qc(Cu4_lq2X&XC#j?!AqgEiqMS z(z@tB9d041yvMv-Zx!y8TB6L7D98B8Mmai2Oi)Mb^QjPJ%QKCo+I#Y5#0wo;=q#Ee z{r=j4*AG<+g9BafX#TpQQGN6B#w$O%dn1H{x-)e5HFQfFXGOp?f z-Yb?LcsH5oPMpS`$$$9hrTv;OID_ZqgiD=#R=JF=)Y5~O;b$`2#mQ}37b*rVdStL5 zeAQFlyJ}a?+_Po#{5Hw{(d3q7&&PKazpdHOEck=@u2`A>#Bg@w5XWDSFLgS7vhx16 zZjZXJtgrZ&`Sbs9DC%xd`Tdq9)iplj>yuye=EW8+`)bd&WWu+tsrzRCf3)ny#Rr*v zXRlR0tdp=d=>GP$cvm;a(;HiK6z06(@#t;5qhZ7J)I&~B%E2$cW>fygHNVy{96h%B zltD__ejatH!rvC>w(Kv->|!;Wp855$ZvHy^M++W*SYGcQJ#E$Fb+&2;1t<7!mE>Q# zGp0d!CV!y+fsNC;^>6NW_^?Fgd{c1F^WgXAt}4m+ujJmv>R6VSwc$b|XPnOKodU0o z=k!@zbXj7ly{$~eadXQXrB18P1^$;B`%-pCbWFNbcy0Y2m1iw?uFJoDHsjDnF8P1G z`A*GxJZ^73wTN2Zd6F4>VrAsE?#_9q<_E~We5$AZqwl~`eKq}% GiyHvv%jWq2 literal 1233 zcmX^3qnzP^&4ygj*WJubJl%;JoPT|5ZiPg5y;vBobhN?ZzN1IrMX&2gQ>X4<`TK6_ zs~Z{p9j$Zpe($$mxYj=Mvq@wHoYqop^ggR=ziV(Ye~X`0bBp`=%cD+9$Pl zQCyVH$!c+}v)(sO*LNLOl{$W1GW>s+v{~cLn-X)Vco1{UuUWN^~)VvDYM^NVRnJb z_BD|wz8UR47PoD#`8Lk^JW6+G+}twv>Z0}cRF=>1WPZqJF37^HqW*oJ?&}~WD}_z1 zQg8eBDpmeDzv7K&+d=*BS9UIL|35$J|AJ5)Uv4bzg z<*QL}TYf{1LCB)#!WXt4b}{^<>~~muM=dM=D`7d;&~>Grr}HMfdhko4c)IN}?%62^ zpOn}!HH3WpCt_=LG3I38a<7INv&2^R^`$UrB<|-su(x)8wtGlTry6_dY%GFu&24AmPzPhIVW$Rnz zgKw)$-+39Oy%XKEI`~|Qzw7OT@%%4rymi;@5)uBd^6c^9-D`#Hrc6HnnT_M4yv(yZ zM=E}te_~`mEB1gkp^;YHse{_F-Xe>Dmpi zG>czIr(`-UnY!>w`<{Ud&aawV=^-@SH0lKn#8 zJljj@mWkh=rB2$byT6^iGv?B)rvm+Vx9sJp;kwTM+izm@>WiUTmX{;drA}rmvi<)v z|Ngm%BmaN>5St*T)LExB@#6Zp7ZJa=Jq^&+ahB=0`sCZYn)RnI&sIJsv!K{@k&w=z MfY 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 .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","@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: 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 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 #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"],"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,uBAIA,iDAIE,yEAME,uBAON,oDHvCF,kBG+CE,SACE,oBKhDF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CHrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA,mICrFA,uEAKE,4GAQA,8EAOA,2BAIA,0BAKF,+IAUA,uCAKA,sEAIA,qCAIA,kNAQA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/reset.css","resources/css/pagination.css","resources/css/theme-selector.css","resources/css/admin-tokens.css","resources/css/notes.css","resources/css/header.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: 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 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 #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 .token-list {\n width: 100%;\n border-collapse: collapse;\n margin-block-start: 1em;\n\n th,\n td {\n text-align: left;\n padding: 0.6em 0.8em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.85em;\n text-transform: uppercase;\n letter-spacing: 0.03em;\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\n .badge,\n .token-list button.revoke {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n font-size: 0.85em;\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\n .badge-revoked {\n color: var(--clr-text);\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","@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","@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"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index d258af4911e60e0e789b20b7c9b38a6b5d8fb617..72d74415073995b6a8e3de562975b98edab3f986 100644 GIT binary patch literal 1762 zcmdPcs{c2oS#YTtgOuM9UJU_X7yb=Q7v^{9YchQg+#GF|Vr1C(;?u#R6dktTkG9{L zv2)F;EDj6N^9&amPBZ8iZHYT~qAvSJ>BF*pJ=dNuVDx=#@bYfAj_2<$Z!aDdyzl#A z)7|CAY7X68@KbnmdrkK~_t>I+>GRjlWm*{4_LlL{&$7upI}d79AF-nWJEdDai$vZsf&DJNy7VfX_Fx|amug=+TmYAG5 zUq(Ob#U=$^uFT3MM<-6}oZV}kc#L)HoJqBFl`P`^PWt!t{lyn2HW}=%m zYI}C6Y);&3pR{eu`4!SRfgA2`jji;TP~wQa$~fuE;d?)|MKp!_Yh?Ik)@>Hz`&@SC zQX{iSqmIGPY0h@fXE=GWTrv<_Y0AU0+Q@IhrxSttyEk4vKf&$$oreKzt1lP7$&O_W zx^_6i;l#x*pUC;k6CXZVT<9Dr!upUWkZ&=YjzCaze`f@rwDaNwThHuIbJnri&Y9b^ zCfR$f-tS_o&9OTR-`AdeKlzxrg5628OP3Dr)ZpT<@-5$^b+g{}m!rhyQ$43kXUF_r zbuZA`Rikd5a+CH;)@T90{Ht}=bLQW@(OY9^^w;VC9@U>WSlBL~T2_3qIkYNvWowkDxaCs z&-d3~DY*Fa^L3U>Wt>(MTJ$2=mt1qN_VatS_49I<>qjEk=U=%NUN5+qop0MrcTcGx zg`>6>t0x5<|K`6kU{a>9%A6yMJys+yU~sZL=~Y&}nw#y=ro>>YNmd(z%(q#e*%n;7 zL3sPu)YG1ibNXNRO*?YO?GoSOHw}B@%aTpM?N^n|dV5~i^Y96``4cD2DrOMk>MBZ^ z;j&gYaDVpG7DiScDT^nIr-+w)_H1xsIB3}g}Et-N0m zsbO|lc_A+^@7@1PI#Q>VS>!o8oa;Fu&{uz_?vOdpMt!2%XMboN9d6_&41$cLc zE_Jj>m~dF_ztrW%$`yqhgFM_)TvR-{CLMF+IFumKdh0Et&_xHnNv9N=JB)&qoE9l) ztqf^16`rtp!2~Jo)m&?w^iJmA7UC}tTM;K%(c&xV#Q7rh%BGLL;4T635X77D* z@fFJ*-H7F}{4bTGw4a7nODcSxbFa+p2-C{l)oJ-3oSN1D+V-$LsO1h_;7>s6 zzLTOg(VQr0?l?zB56m;dd3(_!|ojcdc-aGrHPy4%#0IbJeMIK$bvwYqax zpk?NaX}s%xWKZ5yYjDQeKcIJeCtLOA?3hlazpXcgFIx0WDQ!8eeo*UCK!H(G5v{)R&w2m$9wfyTh2VLV$$Qv;80<8 zS>n26Qo&10zP8x@s-6>N%5{8ZXDZjdmEc~jxwx?Mw5AW+-;-Ze_&+|F=yh$!!IzV& zZ!%^ST?*Qwd8O&v{tY#%6|eFVOj5ojcilPqbk&OLjnSn{YKf;kH-Edz?&)5Z%$fDi zD&*C(lxFECTYh-#t1z%Jd{D2L9U-sHy2QwCZm4>j3&X63wpTw`pV*$~?8wgfiqR#} zfv4Svx!|VFz2?iT7hS?#joepGObz{ic9HXq#tnK7!MA%HWh(AHy6n92OwjCnu{(z= z*ROs)B_WsX_NJtJ1xs6_8ysIQS=;=5THy-WIlWIxBr@1S#142|VY=niQ6YQpfw}x@ zg*(}k4L3F^Oju^nm6T{xamP*EAyjU0b+LWhhI#65GM>&j-4%ZMp4pM&-+Ok7XS;5b z%TLJv#_7ba@UMN&w|%XS0*Xr`mq&>&mgdW9{_AtHKw=xuyw_X5E$}ROZ{wp?sXXnq p$dNX;6PxSKbDhuEE?+nE=)7~^vXw-h9WMW8CnCI>DZs$g3IOtVP!0e9 literal 1479 zcmdPcs{c2If$yv?!zot}9(4uZIqVyle#{oz_K<6P@2tD$G`AdM5Z-wzrLKh6_(2(0 zwJ-~Zpp)wkhHVUc81Bcoc-~l??x)B1;#E`s8@Y{7|IV`fdvTq6iPx^>WLpN+p_$* zrMzc_(Q4N)Evt6@C2Mup>;91qtk_W3+iF|1BYh$_V@6`bG}bL97mPCRt`L>dYEM4? z)p*v0u#et9uI_ekR_?O<5fFOr?Cy;+7efnzJ$E_I{eN@ohVO@ax9VJYesbO%_1_{a zf^L4FmV3(2)IGZ2|Io8L7f-aADqp`gbye5m6|T&W_tu6ic^sp|V-QudAy=b&kwT5) zO3x*XJS<1Ll5;Vy_cOn+0MGAKJeI*3sa%SN;9_>J!hh7p8qKLJRm# z%;A3)HRp`iZ|O-|UKhT}9J%6Oetr@s-xT3)l|=W#DbM20t4!p5`u@a(N2gW>m~^!& zP2_E2?zr-3*@-tfMnV&M&txqqxiLj6C0{Ic`b!6Ozvu3Kx`~@zlj}sLtmtW(t$O{T zMSaRPwI}6T>ki+D4Hl~7Z@uT=G(-P zi$_jt{M?kXPF1en@?rNfrN8%1Br$6m*tHPA<<$y?kQRRGWa- z$dpjUZ#K7+zkhJ=jXjt2)F*G-62nWr+svOv>Aim`xcJuIlmze4Qm5 zoAD=nO=rQHY~9|J?Mtd&YdJY6%v@7+ZTgZ%y}i>ezsSs-lk)9+e){H*jRnq@4pO#aS0mn?xWctm_EOKYrSH=p=y!Aa z{Zlzwe%4)0@c!Yqm3(y(rHhzEnH=n9C&o9;d|0$0FZ!BPv&^0N_Xbb)6{<^Fsrk-3 zDz(8NZQjAxB7uv&Cxi&b3M#lZ`_8doD7(0QqM!dXTom@%bm0mukE97A^l)5W&Gd`;eDPQ0wyI^#5M(F6R8$ z=(cV$*Sj9=H4`lC6}Xt^WTtnBho5__U?D4TRwcA$`NEW})zu#rEfmbUpI`jlu8&?Ziq|K7fnxVShAZ|5}-oc2} z1Zn+8-zpyKgmwFH@^k6`ZgFAf_~WcqF@H}&(Zgkxi+731_84#Rt~-&sMk0f)yL6Xz zhUyOse!ZyyljoMqN|w#|bo2e~hu<@n&hzfPap|U-Ur6-6AAOhF5AlSaGRmF-0EE@J ASpWb4 diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css new file mode 100644 index 00000000..42d83b13 --- /dev/null +++ b/resources/css/admin-tokens.css @@ -0,0 +1,65 @@ +@layer components { + .token-list { + width: 100%; + border-collapse: collapse; + margin-block-start: 1em; + + th, + td { + text-align: left; + padding: 0.6em 0.8em; + border-bottom: 1px solid var(--clr-border); + vertical-align: middle; + } + + th { + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.03em; + opacity: 0.7; + } + + tr.is-revoked { + opacity: 0.6; + } + + a { + word-break: break-all; + } + } + + .badge, + .token-list button.revoke { + display: inline-block; + padding: 0.2em 0.7em; + border-radius: 999px; + font-size: 0.85em; + 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)); + } + + .badge-revoked { + color: var(--clr-text); + } + + .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)); + } +} 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/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index 8836ab07..fe787ad9 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -7,21 +7,41 @@ @if($tokens->isEmpty())

No tokens have been issued.

@else -
    - @foreach($tokens as $token) -
  • - {{ $token->client_id }} — scope: {{ $token->scope }} — issued {{ $token->created_at->diffForHumans() }} - @if($token->isRevoked) - — revoked {{ $token->revoked_at->diffForHumans() }} - @else - - {{ csrf_field() }} - {{ method_field('PUT') }} - -
  • - @endif - - @endforeach -
+ + + + + + + + + + + + @foreach($tokens as $token) + + + + + + + + @endforeach + +
ClientScopeIssuedStatusAction
{{ $token->client_id }}{{ $token->scope }} + @if($token->isRevoked) + Revoked {{ $token->revoked_at->diffForHumans() }} + @else + Active + @endif + + @unless($token->isRevoked) +
+ {{ csrf_field() }} + {{ method_field('PUT') }} + +
+ @endunless +
@endif @stop From 6a0bb623fc79e389a0ab5cb5660e241a7322ed69 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 21 Aug 2026 16:05:48 +0100 Subject: [PATCH 13/25] Restyle the Micropub tokens admin table Breaks the table out of the centred content column to full page width via a subgrid on
(adds a reusable .full-bleed utility), and reworks the table itself to match a Claude Design mockup: a rounded card wrapper, scope values as pill chips, a dotted status pill, and proportional column widths. Colours use the site's existing --primary-hue/light-dark() tokens so it stays correct in both themes. The card scrolls horizontally on narrow viewports instead of clipping content. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LHN7V9kyZxrsxaspMSqoGJ --- public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1477 -> 1690 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 1762 -> 2010 bytes resources/css/admin-tokens.css | 84 +++++++++++++++++-- resources/css/layout.css | 12 ++- resources/views/admin/tokens/index.blade.php | 80 ++++++++++-------- 7 files changed, 135 insertions(+), 45 deletions(-) diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 1d99e7bc..2d06d701 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%)}}.token-list{border-collapse:collapse;width:100%;margin-block-start:1em;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.6em .8em}& th{text-transform:uppercase;letter-spacing:.03em;opacity:.7;font-size:.85em}& tr.is-revoked{opacity:.6}& a{word-break:break-all}}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;line-height:1.5;display:inline-block}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145))}.badge-revoked{color:var(--clr-text)}.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))}} +@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{& 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%)}}.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))}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index e2d1ff369d415fd1742a121226c0afdf863f2167..9ca789b9af38df7f5e3f37fefbeae7419e9b5312 100644 GIT binary patch literal 1690 zcmcckVH3jx9iNA9k~9ypCQ0-yUH_?f&6UL3SzeV}>b7u&Ef2VC6V4{-!M1Ow>+M-# zaZxtcmNfZX&UdKX7`!cwe{#syhqABE$a5|$k&!RBQ?qNcl%w2@!jqm8!{?f7Vsea)l`)2#A&e5=mN96Rwth>v$wc&+2ysT;#Piz242 zcUm8y|G8uHJyXrIzu)XG5N-FKawh+FjZMv~+BdbU>}Q`7Sjn5{RH0g&ecf2B<=E@> z4}SNoSo`+B;+B73GdRmTtkUbgJT%I~)(E-@fONvb)w|0Cn!W0eWndC_jm zd_MP!UXVEYCL!`P^W}~5CJXn5MG0{xm|ZMevAXKj`2f`={%v1ZFf;A!Su?HwLecY8 z))uX;suB?{%Vv5Q-d-?S>eSD^jK#hmi(AaB-@pDF_wcag0YAmK@9D*S9UVo@Q`dYt z_{p^R_JQdcT=fZ?+3(05`zAeQ^>T-oJKS&iosZZq|6l&Ed2W>3vnfxHD$iTAZRNFP zd>vZnS6`iLEq0feT$S!__|2yKVuzMt-O8)Imme;W-6yEU@Ich< zT!xNNkJeY87-!oac5SUqd69P}pG%ysJj49o@?P`5iweKHx<9*jd~3<6oYHHTIrepD zZA*Mvw13vjAMXCn5{I6tw;cTbXMdlKOU)j~|}|b9~v$>`SF#WLyvyUztZ|m$4UO}{1YpG-<{4d(^2JOd9BFX zDt}$?4C@oYo2LD*m~d>n;%coXX!gOT8efG zd}|8QWLufyAai=7_Qaybo{qwmLfuPB+#F_#KjFx07r81jWm8M!qz*O@wdkF@E{V-j z&D$#_-PZi3>zM8i;rOcp+utscun@eHV9nXPq%rpImwe6hnJTZ{w@sfXEsd9h5k@W-Rp{tL+mCu%Y6-nra#+RE5%gq_La9j^j$mS#WZ#Lrqs;*2WH;q3p^V|CPZ;HX)jcH zn3V0CD_;2O-*VZBo?;WYE5BS*Teu}e(sJgPF6~5-H+OuSRz=P|ks!3xtM%}t{alNk zb}KEg?OW%Xu`eZRr+euhx0ip+?l$e0o&LDcwD2v9=UV~iXH)NNh?G6S(feI;!!M05 zmec>|1g_qz+OuPs%SZL3Wz1_^chnzQApgRp`Yp!_*|W~EdWpVs_ejqwSb23`57P?YZL%S!rGB@kggiMJ&=+-J!f}_id9kN6F1(v_$Vr&T zWYGo@!82+nmb4YrsdcScUB?_QcfQzq_qpP7zJQK5rTMcrFJEI$zTMY$nH^X)r97Lx z+{A#VsokUgcx1`?`ha+4hLDZ1Yv)XQ!n+i@K1W*J1u=`x$FOgruDPhBSR)ssiyn&+dYZQKXvPe-kXbS zx6BcJdS$uZ^4C|(w%OGfeJCkc*%`N0?AWcZ_im*>a;Z2F-Xgg9{O?6>>`-g(cbhW>>?euiro@igC%uIt#` zeoH}pgBe98UaiuY*;)8-#)u9TNc^^Mz}t^dR=KzhOQIlFQU z?;c*}sw93o?U>`LZI1uO-`(Qc(Cu4_lq2X&XC#j?!AqgEiqMS z(z@tB9d041yvMv-Zx!y8TB6L7D98B8Mmai2Oi)Mb^QjPJ%QKCo+I#Y5#0wo;=q#Ee z{r=j4*AG<+g9BafX#TpQQGN6B#w$O%dn1H{x-)e5HFQfFXGOp?f z-Yb?LcsH5oPMpS`$$$9hrTv;OID_ZqgiD=#R=JF=)Y5~O;b$`2#mQ}37b*rVdStL5 zeAQFlyJ}a?+_Po#{5Hw{(d3q7&&PKazpdHOEck=@u2`A>#Bg@w5XWDSFLgS7vhx16 zZjZXJtgrZ&`Sbs9DC%xd`Tdq9)iplj>yuye=EW8+`)bd&WWu+tsrzRCf3)ny#Rr*v zXRlR0tdp=d=>GP$cvm;a(;HiK6z06(@#t;5qhZ7J)I&~B%E2$cW>fygHNVy{96h%B zltD__ejatH!rvC>w(Kv->|!;Wp855$ZvHy^M++W*SYGcQJ#E$Fb+&2;1t<7!mE>Q# zGp0d!CV!y+fsNC;^>6NW_^?Fgd{c1F^WgXAt}4m+ujJmv>R6VSwc$b|XPnOKodU0o z=k!@zbXj7ly{$~eadXQXrB18P1^$;B`%-pCbWFNbcy0Y2m1iw?uFJoDHsjDnF8P1G z`A*GxJZ^73wTN2Zd6F4>VrAsE?#_9q<_E~We5$AZqwl~`eKq}% GiyHvv%jWq2 diff --git a/public/assets/css/app.css.map b/public/assets/css/app.css.map index e224fe5b..88c58430 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,oBKhDF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CHrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA,mICrFA,uEAKE,4GAQA,8EAOA,2BAIA,0BAKF,+IAUA,uCAKA,sEAIA,qCAIA,kNAQA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/reset.css","resources/css/pagination.css","resources/css/theme-selector.css","resources/css/admin-tokens.css","resources/css/notes.css","resources/css/header.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: 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 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 #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 .token-list {\n width: 100%;\n border-collapse: collapse;\n margin-block-start: 1em;\n\n th,\n td {\n text-align: left;\n padding: 0.6em 0.8em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.85em;\n text-transform: uppercase;\n letter-spacing: 0.03em;\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\n .badge,\n .token-list button.revoke {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n font-size: 0.85em;\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\n .badge-revoked {\n color: var(--clr-text);\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","@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","@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"],"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,2CGrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA,mIHrFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/reset.css","resources/css/layout.css","resources/css/admin-tokens.css","resources/css/header.css","resources/css/pagination.css","resources/css/notes.css","resources/css/theme-selector.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 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 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 .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","@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","@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"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index 72d74415073995b6a8e3de562975b98edab3f986..d7479672cf281dfbba69bb5d09034c9262bf0692 100644 GIT binary patch delta 2008 zcmaFFdyAh>W2^q(lz7opJBF-~4}25(r0S2HW%}#>|4ziUO{-QZF9^BE@QC37!=E?D z%ARyjd>Q`hx$nv2Y9CzZT{~@gGoN?2(VcVCFaDY{<$(qN;(L2fOxhBDUoKVV<6UO` z+k3OlsL%186>nI5V*88gE2__{zumm0%a@gULrP&@zWK=s(mk8p-!Sh!IN@j4spBVp z#+&9{v3XOkc|1F7YmUXwU2jrkYHBAq9Dd20P%8T@WxeYB!lUH(NgG z{_twjnYOS0IwddLEzdJ>J6LzU>}d6&3l1Cq>%Od*u=Lu$JCo{7=Hz^_)C?(pw@OCE z)A`NZ{Z;;~eAi14A8Xn$OUjigC?h1)u8)=Pdt_&OrFGt_1CCt|Vm|wxPB?G5?2=HU z1y5TRAM?RgJyVzb_@be|d*jvf6WqSv`53^q`fBl;?7OZ}Vn;d@dVC@kS11;Jy0~Mb z!Re-gHVyy9DlQzW4jy;Wsc-K+7`S2G3e&>A^Dm<=9lohKp)_aJ)zo^w^ljSa@Am5d ztiAMu>4^GGwlJ>7$O&F9C^$fH^6yW!0+#nG;`b(3JPG z+_31*jeiEW{iZh0-LY0b+D|IId6}!}^@(2&YgntipME!jmqCb&>*)=X&Zy``b=OaE zGI5CLESMr(@;NKawZVz$z}}t=M#EK)Etpg?8x>TG-uV_RnxB{0A=ba?!;Q8*`Fqwp zvEb%utN z?ZLNWT@i5`_&!OM%d~kMzF;ut*Zm36rODbGrDwGX7FC~@akecL;}&}DCAn+bp3YiR zb)N9UZ2PCay|Y+N^PBQQZhsa_Ufz3Kz9rWc&;MCoC~>sj>BP$!2RtRe`iR$VxXE%{ zYpe2alb7v{ZCf_?=_tCF2Yq_9R`7}q-$};C(<>S`26?%-xTtt?O*)pyVc6xFp?L4n z0h4nwSKb^|;o_<@GY@%nNtcwB{|FlNtA@;fvMBhi|h5-nRuF`ugF(f_APwM`wh! zZalKwQH(q8634}vTn1rh)^qW>iv-Ag+4SJ*SJgFK%NTYP@!UBOe2&w6RddsW`QB_R z6}azAlfG8TpvHK9qDPef=LOrMx2~7|x>951g6*QFoWDG>!@qL9{=2~=vEHAr_!^t9 z0PmguTW0LFS;4Pi#k}{Ta`vLl^J~sCW|!uKXBNa?sWfEXf7bHvtlks$OQkysorJu$ zrs`Q7IPfXvrgczHpt_p?>)SRJXXDDPrvxgJ7T*4_W&adAo79UopRc~JQhU?!%D|LG zuASBU!|R}V&jjq6yK2|>`=$mcANW_#XnrSW!Ik4$Ik}gF+l*_o1oT)+-~PEiyT6y? zPTsU>OG^{v(M?62Vk)^t!MR&!+0PF;$HTLmQQ@n{m9=? zZ+x@Gc*^~6D$=)~$x1QzW^))jn6bxR{%*g@ykJ#)l#9 z9gEcl3Esf(vm0lY&1Rg*)?|>jZ%)Y>&cC8{8=~u5kBO>qedyeL_T39R<>v_p^P>3- z18x7!IC$fC)~-ipZtFkQyqzx0+;&-ul( zzCFokH2mxo9P>EBdr1W&m(uk`F~2Up5;?Z<_3Adv^;uO%rX4*#^z}8 zyC0Z^Q!OjIP2G5SSd1DqwF0W|)}BySm~wjSm9{wwe)Cp;S{Yq06vgzlx7zRB9+{Qj z9Sf|ib5721e~_Ulq7YcMPjus6#=dm+n#WURBWC5#zQ4tl`+$j)xOtM2M{S9-STFl|y5qCM4h{xQcHHb6Sk%uo`>Q_cP_=5k nXXAA5N3DrT=GKkNij_9K)Oq+kCf@G%x#R7#(n5`n-0TDZuSd;a delta 1758 zcmcb`|A?1QW2^q(lxD%DW(-n(M|dalN!6$5u>F3t{mzV?YgT1(ScslyxX5stLBD8A z+_@8V**8ibmhJ1g_Iv@O?_-0Pce`~we}8#<@u=W^-w&JaEY)dp#v>GN0AbkF%8{8&$-eeaVB-L=zr)#Inn zFy|X1{W2{@}Osbu$WD)mw(!a0oFTOaj$zWf}^zEN-Ch+}K+p|k$bK+k6q-|TyuaM3O z+;D$uY^A@15=ZP+#z|ie-}|X8qAAp0Bf~GVZnF^I=dwGO8ta)w8g&eQPII<d(|)kc03KAi~E-@Wnb`3Y{{?>r1(TYb6sO?E76(6z%64ks>l`9#iNp7`*| z;zH+05!Q!1fqaYEbOeH$`#U4}q@5Qh*m`DvnzN48cFx?UHOby<^?nyyZI0bp_`de! z`^m?|73@x$UAlB|rv?{Cy_IkI9<7`8uD={5HlOM_T{=7F_o{n=)~*_L>y(?cU$RCE z_~l=%vz{~m?v36WL!-Y=|M#f=yureD`P8!Fi_M``u`6rC*?+tK4Q`hFVz4$VNL%^x z#g}<|J!hVO6}a>km%Cg)62U(I%C+!%!Nu%++h)3ZN(Ct#wY6A1Dd6}w|CIrg zGJRF%99itKB6$IWljTXTvg*~`Y=<@_23t+C+7M*E&HBu?;L;7k+rOrs_I#Yv|GID5 zkvnde_!hru*b`rtZ2E1#s$|yN^SYjgPq@|3pEzk&F@q3SS5e9gm$kZq`?H_6FtYMU zSv*-hMZDy*XM+>dfxSJfZn?AE7+5sVU;bdibvWr@Ad3iZ<^76C4YR|_3we2Y@BUxX zkvgr+BG2L5gKx*WBFZKl|Iu{Nz@Tf{Kl7GdMW2_I?Aj>V)8*Tc`QlFZgxl56#9k&_ z3%vG{tiM)wCVidpf335NA~-B%g)KBE&wIIu-^_Z!##Lo0{c3+5E=(x9Hc|D-3qP|Q zos-?$_2iBfdQ7{umOHKJ_0O2Bm-TtBYAtK7ESgp=%FEo|F#f+dDx0L!HO1NNhi)1p;u=8t}vazJBw5P@3~DObzyUgUtgH@lKbU^ za&gl$6FhwKd^watIX z^_7La_m=LX*!i&+b{~7O=I`O-s!6K$!K>~*Vfj%J9lmD8G7bOm@bDG0mDaL`MjQD> zty<#vV|Sp}$pyVaw^^TQPiwJyYP8Bav7F0vj;ON!oGaIoCfRg^6kNYv&mwguu zX0_j+8YOqj2;KM)#Wvya6Mhl5i|J2Xw36#iJl?Cv+H&S`6_XxU28Rl(%M#ZmlL}s1 z^0meGSM{7IQ?BDPJ5#yttpxXK&BcY4r!{@p{+|4*!vFEXM6YW*4!)dJeUmYx=u*&@ zdd(|M*Ys%LjE^S zCw7H@?Q_2Eb8mGNP+S_hJW71AG+$QpU!Ri&65Dv@z25q5foH*c8y~Go>wLa;`MQ}$=bihOtt9g7aQQzw5#iNL0S2a40FG8ps{jB1 diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css index 42d83b13..a0db29e5 100644 --- a/resources/css/admin-tokens.css +++ b/resources/css/admin-tokens.css @@ -1,21 +1,34 @@ @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; - margin-block-start: 1em; th, td { text-align: left; - padding: 0.6em 0.8em; + padding: 0.9em 1.2em; border-bottom: 1px solid var(--clr-border); vertical-align: middle; } th { - font-size: 0.85em; + font-size: 0.8em; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.03em; + letter-spacing: 0.08em; opacity: 0.7; } @@ -26,14 +39,65 @@ 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-block; - padding: 0.2em 0.7em; + 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; } @@ -45,10 +109,18 @@ .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 { 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/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index fe787ad9..c9443e29 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -7,41 +7,49 @@ @if($tokens->isEmpty())

No tokens have been issued.

@else - - - - - - - - - - - - @foreach($tokens as $token) - - - - - - - - @endforeach - -
ClientScopeIssuedStatusAction
{{ $token->client_id }}{{ $token->scope }} - @if($token->isRevoked) - Revoked {{ $token->revoked_at->diffForHumans() }} - @else - Active - @endif - - @unless($token->isRevoked) -
- {{ csrf_field() }} - {{ method_field('PUT') }} - -
- @endunless -
+
+ + + + + + + + + + + + @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 From 714cd95d79b56c8d7548a357c3a9ef19736a4ac4 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 22 Aug 2026 10:42:16 +0100 Subject: [PATCH 14/25] Drop RSS/Atom feeds, support JSON feeds only Removes the RSS and Atom feed routes, controller methods, and views for both the blog and notes feeds, keeping JSON (and JF2) as the only supported feed formats. Also serves the JSON feeds with the spec-required application/feed+json MIME type instead of the generic application/json. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8 --- app/Http/Controllers/FeedsController.php | 65 +++--------------------- resources/views/articles/atom.blade.php | 20 -------- resources/views/articles/rss.blade.php | 26 ---------- resources/views/master.blade.php | 8 +-- resources/views/notes/atom.blade.php | 20 -------- resources/views/notes/rss.blade.php | 26 ---------- routes/web.php | 4 -- tests/Feature/FeedsTest.php | 52 +------------------ 8 files changed, 12 insertions(+), 209 deletions(-) delete mode 100644 resources/views/articles/atom.blade.php delete mode 100644 resources/views/articles/rss.blade.php delete mode 100644 resources/views/notes/atom.blade.php delete mode 100644 resources/views/notes/rss.blade.php 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/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/master.blade.php b/resources/views/master.blade.php index 2e5c6b5a..f269d830 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -7,13 +7,9 @@ @yield('title'){{ config('app.name') }} - - - + - - - + 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 dd594480..b929a2de 100644 --- a/routes/web.php +++ b/routes/web.php @@ -170,8 +170,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']); @@ -182,8 +180,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']); 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(); } From 99f306ede1a1f1b59b0b4bfb242571500dc298e0 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 22 Aug 2026 10:42:23 +0100 Subject: [PATCH 15/25] Replace RSS icon with a JSON Feed icon in the header The nav's feed link already points at the JSON feed, but was using the feather RSS icon. Adds an SVG recreation of the official JSON Feed mark (traced from jsonfeed.org's icon) and swaps it in, using a single dark-green fill throughout so it stays legible against both the light and dark theme backgrounds. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8 --- resources/views/icons/json-feed.blade.php | 11 +++++++++++ resources/views/master.blade.php | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 resources/views/icons/json-feed.blade.php 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 f269d830..3c82845b 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -36,7 +36,7 @@ Likes Contacts Projects - @include('icons.rss', ['title' => 'RSS Feed']) + @include('icons.json-feed', ['title' => 'JSON Feed'])
-
-
- Select theme: -
- - -
-
- - -
-
- - -
-
-
+ +
From 9532aae37eeac0abc29a6de35097821259c22832 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 23 Aug 2026 10:17:26 +0100 Subject: [PATCH 19/25] Bring back winter snow effect, gated by an admin setting The site has flip-flopped on the snow effect every winter (add it, remove it, repeat), meaning a PR round-trip each time. Instead, restore the winter.js/is-land assets and gate them behind a new admin-toggleable setting so the effect can be switched on/off from /admin/settings without touching code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0113BVPXDpEk9HPEqXvSG2WQ --- .../Controllers/Admin/SettingsController.php | 32 +++++ app/Models/Setting.php | 18 +++ app/Providers/AppServiceProvider.php | 7 + database/factories/SettingFactory.php | 24 ++++ ...026_08_23_101358_create_settings_table.php | 28 ++++ package-lock.json | 20 +++ package.json | 4 + public/assets/js/is-land.min.js | 2 + public/assets/js/is-land.min.js.br | Bin 0 -> 1834 bytes public/assets/js/is-land.min.js.zst | Bin 0 -> 2082 bytes public/assets/js/winter.js | 125 ++++++++++++++++++ public/assets/js/winter.js.br | Bin 0 -> 1117 bytes public/assets/js/winter.js.zst | Bin 0 -> 1329 bytes resources/views/admin/settings/show.blade.php | 26 ++++ resources/views/admin/welcome.blade.php | 5 + resources/views/master.blade.php | 11 ++ routes/web.php | 7 + tests/Feature/Admin/SettingsTest.php | 68 ++++++++++ 18 files changed, 377 insertions(+) create mode 100644 app/Http/Controllers/Admin/SettingsController.php create mode 100644 app/Models/Setting.php create mode 100644 database/factories/SettingFactory.php create mode 100644 database/migrations/2026_08_23_101358_create_settings_table.php create mode 100644 public/assets/js/is-land.min.js create mode 100644 public/assets/js/is-land.min.js.br create mode 100644 public/assets/js/is-land.min.js.zst create mode 100644 public/assets/js/winter.js create mode 100644 public/assets/js/winter.js.br create mode 100644 public/assets/js/winter.js.zst create mode 100644 resources/views/admin/settings/show.blade.php create mode 100644 tests/Feature/Admin/SettingsTest.php 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/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 68367a97..a40ae43f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,12 @@ namespace App\Providers; +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 Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; @@ -63,5 +65,10 @@ class AppServiceProvider extends ServiceProvider // 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/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_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/package-lock.json b/package-lock.json index 50949213..05c9ad3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "jbuk-frontend", "version": "0.0.1", "license": "CC0-1.0", + "dependencies": { + "@11ty/is-land": "^5.0.0", + "@zachleat/snow-fall": "^1.0.3" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.1.0", @@ -20,6 +24,16 @@ "stylelint-config-standard": "^40.0.0" } }, + "node_modules/@11ty/is-land": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@11ty/is-land/-/is-land-5.0.1.tgz", + "integrity": "sha512-Rh/sLhE4vrc2JaSjeY385v2UxnDY9BhnQtitETb3SKyr0A48Q5Vn06q2AvDBHObtk9+dcFWsoZX4jhT+O9g+xQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1018,6 +1032,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@zachleat/snow-fall": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@zachleat/snow-fall/-/snow-fall-1.0.3.tgz", + "integrity": "sha512-Y9srRbmO+k31vSm+eINYRV9DRoeWGV5/hlAn9o34bLpoWo+T5945v6XGBrFzQYjhyEGB4j/4zXuTW1zTxp2Reg==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", diff --git a/package.json b/package.json index 9c08f32a..b0ac9882 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "compress": "./scripts/compress.sh", "build": "npm run lint && npm run build-css && npm run build-js && npm run compress" }, + "dependencies": { + "@11ty/is-land": "^5.0.0", + "@zachleat/snow-fall": "^1.0.3" + }, "allowScripts": { "esbuild@0.27.7": true, "lightningcss-cli@1.32.0": true diff --git a/public/assets/js/is-land.min.js b/public/assets/js/is-land.min.js new file mode 100644 index 00000000..17203312 --- /dev/null +++ b/public/assets/js/is-land.min.js @@ -0,0 +1,2 @@ +//! +const e=window,t=e.document,a=e.navigator;function i(){let e,t;return{promise:new Promise((a,i)=>{e=a,t=i}),resolve:e,reject:t}}class r extends HTMLElement{static attributePrefix="on:";static attr={template:"data-island",ready:"ready",defer:"defer-hydration",type:"type",import:"import"};static _tagNames=new Set;static _once=new Map;static ctm(){return"undefined"!=typeof globalThis}static define(t=e.customElements){let a="is-land";this.ctm()&&!t.get(a)&&t.define(a,this)}static _initTypes={default:async e=>{await import(e.getAttribute(r.attr.import))}};static addInitType(e,t){this._initTypes[e]=t}static _fallback={};static addFallback(e,a){this._fallback[e]=a;let i=Array.from(this._tagNames);i.length&&t.querySelectorAll(i.map(e=>`${e}:defined`).join(",")).forEach(e=>{e.replaceFallbackContent()})}getFallback(){return Object.assign({[`:not(:defined):not(${this.localName}):not([${r.attr.defer}])`]:(e,t)=>{let a=r.renameNode(e,t+e.localName);return()=>{a.shadowRoot&&e.shadowRoot.append(...a.shadowRoot.childNodes),e.append(...a.childNodes),a.replaceWith(e)}}},r._fallback)}static renameNode(e,a){let i=t.createElement(a);for(let t of e.getAttributeNames())i.setAttribute(t,e.getAttribute(t));let r=e.shadowRoot;if(!r){let t=e.querySelector(":scope > template[shadowrootmode], :scope > template[shadowroot]");if(t){let a=t.getAttribute("shadowrootmode")||t.getAttribute("shadowroot")||"closed";r=e.attachShadow({mode:a}),r.appendChild(t.content.cloneNode(!0))}}return r&&i.attachShadow({mode:r.mode}).append(...r.childNodes),i.append(...e.childNodes),e.replaceWith(i),i}constructor(){super(),this._ready=i(),this._fallbackExec={},r._tagNames.add(this.localName)}getParents(e,a=!1){let i=[];for(;(e=e.parentNode)&&e&&e!==t.body;)if(e.matches&&e.matches(this.localName)){if(a&&e===a)break;s.hasConditions(e,r.attributePrefix)&&i.push(e)}return i}replaceTemplates(){let e=this.querySelectorAll(`template[${r.attr.template}]`);for(let t of e){if(this.getParents(t,this).length>0)continue;let e=t.getAttribute(r.attr.template);if("replace"===e){let e=Array.from(this.childNodes);for(let t of e)this.removeChild(t);this.appendChild(t.content);break}{let a=t.innerHTML;if("once"===e&&a){if(r._once.has(a))return void t.remove();r._once.set(a,!0)}t.replaceWith(t.content)}}}async beforeReady(){let e,t=this.getAttribute(r.attr.type);t?e=r._initTypes[t]:this.getAttribute(r.attr.import)&&(e=r._initTypes.default),e&&await e(this)}async ready(e,t){return Array.isArray(t)||(t=this.getParents(e)),Promise.all(t.map(e=>e.wait()))}replaceFallbackContent(){let e=`${this.localName}--`;for(let[t,a]of Object.entries(this.getFallback())){if(this._fallbackExec[t])continue;let i=Array.from(this.querySelectorAll(t)).reverse(),r=[];for(let t of i){if(!t.isConnected)continue;let i=this.getParents(t);if(i[0]===this){let s=a(t,e);r.push({node:t,parents:i,returned:s})}}for(let{node:e,parents:t,returned:a}of r.reverse())this.ready(e,t).then(a);this._fallbackExec[t]=!0}}wait(){return this._ready.promise}async connectedCallback(){s.hasConditions(this,r.attributePrefix)&&this.replaceFallbackContent(),await this.hydrate()}async hydrate(){let e=[],t=this.getParents(this);t.length&&e.push(t[0].wait()),e.push(...s.getConditions(this,r.attributePrefix)),await Promise.all(e),this.replaceTemplates(),await this.beforeReady(),this._ready.resolve();let{ready:a,defer:i}=r.attr;this.setAttribute(a,""),this.querySelectorAll(`[${i}]`).forEach(e=>e.removeAttribute(i))}}class s{static _media={};static map={visible:s.visible,idle:s.idle,load:s.pageLoad,interaction:s.interaction,media:s.media,"save-data":s.saveData};static getMap(e=""){return Object.keys(s.map).map(t=>e+t)}static hasConditions(e,t){for(let a of s.getMap(t))if(e.hasAttribute(a))return!0;return!1}static getConditions(e,t){let a=[];for(let i of s.getMap()){let r=t+i;if(e.hasAttribute(r)){let t=e.getAttribute(r);a.push(s.map[i](t,e))}}return a}static visible(t,a){let{promise:r,resolve:s}=i();if("IntersectionObserver"in e){let e=new IntersectionObserver(t=>{let[a]=t;a.isIntersecting&&(e.unobserve(a.target),s())});e.observe(a)}else s();return r}static pageLoad(){if(s._cacheLoad)return s._cacheLoad;let{promise:a,resolve:r}=i();return"complete"===t.readyState?r():e.addEventListener("load",()=>r(),{once:!0}),s._cacheLoad=a,a}static idle(){if(s._cacheIdle)return s._cacheIdle;let{promise:t,resolve:a}=i();return"requestIdleCallback"in e?requestIdleCallback(()=>a()):a(),s._cacheIdle=Promise.all([s.pageLoad(),t]),s._cacheIdle}static interaction(e,t){let a=(e||"click,touchstart").split(",").map(e=>e.trim()),{promise:r,resolve:s}=i();function o(e){s();for(let e of a)t.removeEventListener(e,o)}for(let e of a)t.addEventListener(e,o,{once:!0,passive:!0});return r}static media(t){if(s._media[t])return s._media[t];let{promise:a,resolve:r}=i(),o={matches:!0};return t&&"matchMedia"in e&&(o=e.matchMedia(t)),o.matches?r():o.addListener(e=>{e.matches&&r()}),s._media[t]=a,a}static saveData(e){let{promise:t,resolve:r}=i();return"connection"in a&&a.connection.saveData!==("false"!==e)||r(),t}}new URL(import.meta.url).searchParams.has("nodefine")||r.define(),e.Island=r;export{r as Island}; \ No newline at end of file diff --git a/public/assets/js/is-land.min.js.br b/public/assets/js/is-land.min.js.br new file mode 100644 index 0000000000000000000000000000000000000000..34fe76f2943ce1586ecfa2013bf766c68599d5b7 GIT binary patch literal 1834 zcmcckVF5#eNMH5((-)$2g1MD9PwQF{ztZb@JOejZ$z9JfnVYdui#LaytumkLV$&g8 zkyn`K`Q~^)ttQ6~7i({}rTu&U-e2i?d{MiVm?G?{`@U%Xa75|BXx7huTPIEPWifvm$n&cefND7o3L!6 zw4MHu+Y5DO1XfN5G8X8_TxqeHxZI@J;N)GqQ!&#yCwp*7 zY}YuMFje9AdFe0TY*bIPtlV1oDkbYw!2;&gC6oW|sD2Q?Q_u9~$+K_w2OnL))zaiu zcAd*5Li=X2)SlJN*>Cf|m5bgo`2H_oszT738((@)PrU!tvv2OXODdOVOQ#t%{;l+k z@0utpCb`$@tn(h9az5jgFDGo3ss0(>^LN$lOQ4;(mh1Dzt1P*2W0RG&b%Du8hEq%rqLbGO{@l2% z&X&WGSD&l$jaAO;Ka59|cZwf6G4b%!!|JQ-HgikQem(oRJE!ft`K@Me<4mfAnr|iE zuDX+9;;{btuBUGjjCihk7CrM2d@oS`!#8quOU}*tFZk@=9<^S2*8HW(`b*I*lYZ2d zteAhG@=fT^Kl^+Qm_8}VZ|jClu$-mEF2ERZp+jv0m2t#e`B>6p4vWWKF~9c5Z|lGR`9#S6Vh+Q5uKb#YpVew+9r;(VGrwm| z^8U!I904^S2K~M3`>jx{&T9-*=rnUa|M}Jrk>hylTeDK&rpK9H; z%gOY)J-5DEO-(x%ExA>K=QkZ&}DPfnQ}z?G8D= z`oP3R=dX$c3RSOgI5YFojF}!KI`-im1$Nx~og#}GK3)^;I_^I4da$eYFO9+jf2BI> z{`K~2na!Sip`TyrnER*FDNhA-WS5^1S?%)kJD-$c)U%7Hzp99TD0c|5xXi}E<96}> z&Ah7-d~pxno>*_u@wYqahg7agLcaJEN0)XlZKp$%@0uj_1uc1?DsLUjR~{$v(%^dl zYt6oQMLbLNosKMACh~yq^z+wKN~6nP7V6w!3(K0}yF+m2)ivcCJFic3iOeY1xEUX=R_dyo^t=4%`do-nYNAeZz~q2F>oTn`UV;dDLIZ zI%t%Z@$K@<$Nz3d$xUB=>Asrea-|=JPmX#7FI_4Br~A?kNzDn7s)E)lrawO4HEn&{ zY~|K<@y?7g4lZBn3Zk#ce@ltwR@6`V=5^|h%LdE6QhQ^Roz|P{wk$hbYq0cf_Q71{ zoVPchhS|U0{&RY0__^uZOLJ}-3KuV)ePiW5Mc?V=JDq3dzdltopGQ*S=*BR9!>$uE zv>D@7#FV+}T=njpsQDhUv(@7Mhi(szr^_El+eII~d!;hzgo-z3;-ZId?b&*k@$^28 z^fmW;KH=W7%0{0BFaP%+5NcNrUM*Vl^jXHqsved0!-*oD6X)v0uK(8%#98#b)=%z( z0o$ZW@pU>+5~|jI-v8z5`c)N=_N{Ps-okYz$1kMN%>2G;X*B!p``HV49QbW~-v279 z((&6_ZM!Dw0+&cc+w8C@7oFB?IVWySzt76~=uKYV&6RzXFW=m2mpb_~A}6)G^L(*y`@z z_Ly@%bWYLyQ}cTT&z|G@`u6yycVENgH*RLK)+*y!XgvFwu@1NH2UC}slV|&?Za=g9 OQkCavKJV~_Hp~D5aDl`C literal 0 HcmV?d00001 diff --git a/public/assets/js/is-land.min.js.zst b/public/assets/js/is-land.min.js.zst new file mode 100644 index 0000000000000000000000000000000000000000..f878449071c3b6d393e2a050c9071f3b6bd645eb GIT binary patch literal 2082 zcmdPcs{c1dUU;bkLsm!&pDM%TJDICJPEL7yD3(bv?cx{HkNbU}=cmuC<$v!V|K#MK z|Mx==vOHjT#Bhs2e2Ij7>YCR-Y`5p1`Q732NiK=UXz8+c z)}BjA*Uern?ozdy^!Yc_rDYXNhxZ3cR^Ll^bv?f7to1RyTqEW;ANOr}B2jH7wB6e6 zF@wGLo0rd5F8ZaV%I++jvV6t0 zdb_N?vg`iY%RF!2y~c6h@C`R(Udf%P*3TC|WlF7lc~$l9ud)}h*7q-O)wL=**1x2* zvBoZWQQOn(6Yo-k!%UW5|8V#Cl$1XWZ*8U;GdWC9@n<%Xvk+uz66zFpd6LcjC+6*= z`LoYyCMhbOzVY9v>#@cZe;JnLt0gbCZ*6-&v8+?z%iVJS_&d4h%rka=E#AF5>UG)a znkx^=yq9XGDb>9<^(+hhnBDp0`Ia9ugwkI;-lnT?mhqXw17t;)nZ*Z$ykh|`F z^}FAS?lZTPabH}$Re>or{a@wN_g?ZVV{5d&e=52!5yYX%I$2|D&wJ&IcegFOH|yK> z-xu{KA9|-gwXxCb?%i*{e(t>6c7NZ+9dEyxc0O6S=dj;~7u#w|MQ(lT6Iq@%{dVy* zk0)QI8HHtRH=Uf7e{8|Flsil<>jSb_gLzB^CoWJtA ztdl!;NoFN~V#@048>C%kO1La2cAczJC*C8qMA7N`RM+)e_Z)loB6|MufT=3}B8L{f z`N4W=f^)~W8&i6M-o#AooHKjh+&fi&eD{haP7KW6yi7PgqS@e6gi@mB^K1*%87`m1 z=e(P}tf+MP>!*2NZslDLy!iid$i?k%=FU%3%HH?7Bgy;}%dYh8h29mn&hbB#*j20d zTq674ruoM@rmxMMpi=iY=W|QlzwZC@1dH1iv03^qU-w(;)~jnj<+p3DZJ%gjx8%c- zi4!Zc_pEGPr0LSVA?W|HDHe$t53NO5<$fK@Vi0xtCjT`1KkGi-+>*<(J)Q;zb<^0Q z4o|T>eoudKcLq)B_br(Ps?iEuIc+kB6R)uxX{!)6HtJ2|Jk6ug#`m{y zl0d4GXxE~fch)bk4OC&wams2ERgnGUA>=%x(Z|E^*10Uv7n_r2g>RgwJ)Jv)Q|9h* zr#E?<*>hNrg|6!@Jj$|0cb1Y-y}MXclEmGQvWG9OSm*Lx_JZ(~cubjHqX%QO}d%<+u52=gGR;A9X8M+Ug`?#a-L2lB$Crno-9n|@G$tqaj-KQz{xP!jAOw&9(U(;yb7a{o> zD;7up%zI;z|HtIuU#~=$>R^vwAGZ9TuJS!}%18Alh1Fe#U7NY~UY}+3aLLE5`&HFX zIO-*NGoN*zX?^#JVath-E2~6O&-+i*w6)2&#`e5t;e*K2E;+&`3@6MpX6TwGG{yvM zyLWLv&yx*J7T>+i_>Su;Di$OMbaIC#C)pG=M_u#X_HK6Du2~I$B>{}SULdh7n3vX^@+KX2M$t^IsU{`BZSx>HMc-H_NI<|4rr zwl!i}^L4}N{4B+1HrYJ+^8RRNdGWro-Ru6iY~}q@D0!g2?80`2lG`(P=TvULqPxh8 zS1y}N#z%jxduA}}(xQa4%MKPcqnkhG~LB4L|Xkn_FN%v2lRpNzh7b6d=+{F#dsr0HeqYlmGw# literal 0 HcmV?d00001 diff --git a/public/assets/js/winter.js b/public/assets/js/winter.js new file mode 100644 index 00000000..c28d74d5 --- /dev/null +++ b/public/assets/js/winter.js @@ -0,0 +1,125 @@ +class Snow extends HTMLElement { + static random(min, max) { + return min + Math.floor(Math.random() * (max - min) + 1); + } + + static attrs = { + count: "count", // default: 100 + mode: "mode", + text: "text", // text in snow flake (emoji, too) + } + + generateCss(mode, count) { + let css = []; + css.push(` +:host([mode="element"]) { + display: block; + position: relative; + overflow: hidden; +} +:host([mode="page"]) { + position: fixed; + top: 0; + left: 0; + right: 0; +} +:host([mode="page"]), +:host([mode="element"]) > * { + pointer-events: none; +} +:host([mode="element"]) ::slotted(*) { + pointer-events: all; +} +* { + position: absolute; +} +:host([text]) * { + font-size: var(--snow-fall-size, 1em); +} +:host(:not([text])) * { + width: var(--snow-fall-size, 10px); + height: var(--snow-fall-size, 10px); + background: var(--snow-fall-color, rgba(255,255,255,.5)); + border-radius: 50%; +} +`); + + // using vw units (max 100) + let dimensions = { width: 100, height: 100 }; + let units = { x: "vw", y: "vh"}; + + if(mode === "element") { + dimensions = { + width: this.firstElementChild.clientWidth, + height: this.firstElementChild.clientHeight, + }; + units = { x: "px", y: "px"}; + } + + // Thank you @alphardex: https://codepen.io/alphardex/pen/dyPorwJ + for(let j = 1; j<= count; j++ ) { + let x = Snow.random(1, 100) * dimensions.width/100; // vw + let offset = Snow.random(-10, 10) * dimensions.width/100; // vw + + let yoyo = Math.round(Snow.random(30, 100)); // % time + let yStart = yoyo * dimensions.height/100; // vh + let yEnd = dimensions.height; // vh + + let scale = Snow.random(1, 10000) * .0001; + let duration = Snow.random(10, 30); + let delay = Snow.random(0, 30) * -1; + + css.push(` +:nth-child(${j}) { + opacity: ${Snow.random(0, 1000) * 0.001}; + transform: translate(${x}${units.x}, -10px) scale(${scale}); + animation: fall-${j} ${duration}s ${delay}s linear infinite; +} + +@keyframes fall-${j} { + ${yoyo}% { + transform: translate(${x + offset}${units.x}, ${yStart}${units.y}) scale(${scale}); + } + + to { + transform: translate(${x + offset / 2}${units.x}, ${yEnd}${units.y}) scale(${scale}); + } +}`) + } + return css.join("\n"); + } + + connectedCallback() { + // https://caniuse.com/mdn-api_cssstylesheet_replacesync + if(this.shadowRoot || !("replaceSync" in CSSStyleSheet.prototype)) { + return; + } + + let count = parseInt(this.getAttribute(Snow.attrs.count)) || 100; + + let mode; + if(this.hasAttribute(Snow.attrs.mode)) { + mode = this.getAttribute(Snow.attrs.mode); + } else { + mode = this.firstElementChild ? "element" : "page"; + this.setAttribute(Snow.attrs.mode, mode); + } + + let sheet = new CSSStyleSheet(); + sheet.replaceSync(this.generateCss(mode, count)); + + let shadowroot = this.attachShadow({ mode: "open" }); + shadowroot.adoptedStyleSheets = [sheet]; + + let d = document.createElement("div"); + let text = this.getAttribute(Snow.attrs.text); + d.innerText = text || ""; + for(let j = 0, k = count; j`{AG~sY`N4r7GGR}La zn6yNy1quxO_gro(-I>YL%6`7Mq0cO^lq>#H(UjQNj@~CHUC*ujvpMKX;~btzTTcYI zue;K;{lJzbA=#NWxAwf^`toe|{J^b&D&=bu`wwo^IbLHXa{0c-d64_h^V>}eKF1773D=^5Ye5CYi z(H5U2?56U?vuD3LYP7fN-<{j}Z09Adb8{BhFLkxn?t6Z5=cA9uH;d<-<~q#Hc{FwJ z2ZzTqx)yhAwK*+*Ky6L=6s1?6Tg-e8IeV(eZz!CQsw&s#K8x3IrP%FNt?M7Wy}f;f zd*+MZYrj;i(sM79E#W^U{$XX#+x)A&I#Ig+mwsXF3Hi&AZB}yQ2}kCoWm^j7pGdhc zuWrBN`>&8GHs_*`wX%21PMfG89dzWd@P2WzTga9J=w{()UADchj2;$y<3$9 zTi)){;1F;%t8#q1wQFOzZu-LzPa}~j3MAIIGvd*3kG-nI@d*5tIddthgpqH_tZD zI^n~5Tx3E1mPJlFC8gZo+V|Nswb^>H83kJ0UcDyaT>tNtk!`p4#ndvFh3&R{ylL^p zd2L^6bSEBj=O~=YH_NkrrO8=k<49)KfKmtT(^KwnE$Td2`^%cw#rD#vUfX;RSL@Y6 z?tfEsTIS2$YLC&JF}rTZ38R&LO656UteCnAR-c=;TzyV}lhoho3K|Q3{=9D=b3Fg~ z#Rp47E<3GU_m^+B##|QzlMg+emI2;pCT~y`J;3HvI%Ad?%lh5s7r%Z~3srJh{Z-Jv zwnW8Kj>FvIyut4s4~;9{|BQanz`Dg<)8+n=F1bl1-W{TgKCFx0=jr7=X|JQJhhW9Q zwL01+E6==GQY`#UC*oxN^Q|xW?y!HjrDVVj;)L p=QnYEeDvv*^1LUON7oq5IQ5j>bz;!}Z0H zh*f6K=bFVZpJ6G(IjNroMt4`m?pw1iRcF3jbYVeO=&{~*dGBMtUqz_CtcbX~W#y)c zfop5$N$-u4{-O9ZjpxZf>685Df9R$^*z;;z3}5}1f-gBn3(xSz$xL5lRyA+!KJ)E5 z(J$?%ln8pVE^@fEPd4I3;P*=38;3hpZ{+T>z07f>@ur*Z=INC=9@i)F?{`y~`}bMT z^5!E)jvl^t=?xiT}1u`fO6vwYAO(MH^0XWG>;8^|^S^@nEx@#4f?FFT>uP)l^G%xv)|E=l%4xT6yo+u4+>~b-yS< zScymZWxS1gbmqT_mYNrxRl9?&wfsH>Xr4sE-w+@Ao~4M)2wp&xee)_ zH_TlhJxIw&Vt6U0J*889OI8kh&!PQ)xEzF=pU){4?)nn5{v_b^*ze1Gt3?y$~%|4OtI`@;g%E5EsD&d8INmk zzIZrI{P@bWf2Et{I?R9dFAgsF-5tf2Xgu}#&INaLm#>)kvGUJ^m8QAV@?Gk3UwzBG zR%89XDRP2e{^7kB!&YyIQGXxS5vum@tn;P%w)8XREa6z5r$SA3sc1DcxMNMy$y3WqM1uU=Gik&%W_b)m7yageJ)C`oE@<+py_IP|Mw{xyxou zP&>bAse6;0@AvYkBKKun&C_LMD`ekm+;QMFKT??LWU-L_+&+=UTGt7k(&rBs-Db1c z)D)%j@L*2l{$?R`>8$hFCKMhKK~-`ybIH7smfCqK<20+7rHut|bQ( z(mI^?Gb)}n^E>{!E!K^hyZ2O|SQ9(v?yicSr}Z=b$*$WyF-a=>&OvbJzFAMom|UW4*@PZ(>(FIh3(FxAHy!%+Y(d literal 0 HcmV?d00001 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/welcome.blade.php b/resources/views/admin/welcome.blade.php index 663cfdc4..03112665 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -61,4 +61,9 @@

Manager your passkeys.

+ +

Settings

+

+ Edit site settings. +

@stop diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index cb15e685..543f4255 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -80,6 +80,17 @@ @section('scripts') + + @if($winterEffectEnabled ?? false) + + + + + + @endif @show diff --git a/routes/web.php b/routes/web.php index b929a2de..3b6fa7d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController; use App\Http\Controllers\Admin\NotesController as AdminNotesController; use App\Http\Controllers\Admin\PasskeysController; use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; +use App\Http\Controllers\Admin\SettingsController; use App\Http\Controllers\Admin\SyndicationTargetsController; use App\Http\Controllers\Admin\TokensController; use App\Http\Controllers\ArticlesController; @@ -160,6 +161,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { 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']); + }); + // Passkeys Route::prefix('passkeys')->group(function () { Route::get('/', [PasskeysController::class, 'index']); 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); + } +} From 9e9fbdc127e983a4a33a9aae313c3930956db6c8 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 28 Aug 2026 14:49:53 +0100 Subject: [PATCH 20/25] Add admin-editable About page Mirrors the existing Bio singleton pattern: a new `about` table/model, admin CRUD at /admin/about, and a public page at /about linked from both the header nav and the admin homepage. Content is wrapped in .e-content so it lays out correctly within the site's CSS grid. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013bzYwaDD8p3XNDMYKvXKrs --- app/Http/Controllers/AboutPageController.php | 16 +++++ .../Controllers/Admin/AboutController.php | 32 +++++++++ app/Models/About.php | 13 ++++ database/factories/AboutFactory.php | 24 +++++++ .../2026_08_28_142830_create_about_table.php | 28 ++++++++ resources/views/about.blade.php | 9 +++ resources/views/admin/about/show.blade.php | 19 ++++++ resources/views/admin/welcome.blade.php | 5 ++ resources/views/master.blade.php | 1 + routes/web.php | 11 +++ tests/Feature/AboutPageTest.php | 26 +++++++ tests/Feature/Admin/AboutTest.php | 68 +++++++++++++++++++ 12 files changed, 252 insertions(+) create mode 100644 app/Http/Controllers/AboutPageController.php create mode 100644 app/Http/Controllers/Admin/AboutController.php create mode 100644 app/Models/About.php create mode 100644 database/factories/AboutFactory.php create mode 100644 database/migrations/2026_08_28_142830_create_about_table.php create mode 100644 resources/views/about.blade.php create mode 100644 resources/views/admin/about/show.blade.php create mode 100644 tests/Feature/AboutPageTest.php create mode 100644 tests/Feature/Admin/AboutTest.php 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/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 @@ + + */ +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/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/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/welcome.blade.php b/resources/views/admin/welcome.blade.php index 03112665..132da7cc 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -57,6 +57,11 @@ Edit your bio.

+

About

+

+ Edit your about page. +

+

Passkeys

Manager your passkeys. diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 543f4255..b33d1fa9 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -36,6 +36,7 @@ Likes Contacts Projects + About @include('icons.json-feed', ['title' => 'JSON Feed'])

diff --git a/routes/web.php b/routes/web.php index 3b6fa7d2..03d865b1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('login'); Route::post('login', [AuthController::class, 'login']); @@ -167,6 +172,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { 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']); 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', + ]); + } +} From 568ae78864aa1f955c80000afe44c790b994cea5 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 13 Sep 2026 10:40:27 +0100 Subject: [PATCH 21/25] Add manual Micropub token generation for non-PKCE clients iA Writer's IndieAuth client predates PKCE support in the spec, so it can't complete the normal authorization flow. Add an admin form to mint a token directly (reusing the existing TokenService), so it can be pasted into clients that support manual token setup instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy --- .../Controllers/Admin/TokensController.php | 32 +++++++++ public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1690 -> 1792 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 2010 -> 2117 bytes public/assets/js/app.js.br | Bin 1269 -> 1304 bytes public/assets/js/app.js.zst | Bin 1475 -> 1502 bytes resources/css/admin-tokens.css | 26 +++++++ resources/views/admin/tokens/create.blade.php | 52 ++++++++++++++ resources/views/admin/tokens/index.blade.php | 9 +++ routes/web.php | 2 + tests/Feature/Admin/TokensTest.php | 67 ++++++++++++++++++ 12 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 resources/views/admin/tokens/create.blade.php diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php index 1b5348f9..02b9660c 100644 --- a/app/Http/Controllers/Admin/TokensController.php +++ b/app/Http/Controllers/Admin/TokensController.php @@ -6,7 +6,9 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\MicropubToken; +use App\Services\TokenService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\View\View; class TokensController extends Controller @@ -21,6 +23,36 @@ class TokensController extends Controller 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. */ diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 5c50c0b4..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: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))}} +@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 9ca789b9af38df7f5e3f37fefbeae7419e9b5312..200e0d258d986670b66f63ebf5d4d71b53405b73 100644 GIT binary patch literal 1792 zcmcck;Sj?E8K3ROt4(x$ID$W=y-!zGnfdkf-qXn;>(tGEugG&2{?+AFtMH}8e`S8z z%vIN|3)K}kkDS=CE^dd~(MXHzQ2yYp5C7he%Hj;sN@ZAjs`iE_o70B!rF%spzwTXV zduM^xq;%Qn4g2z(YfN5TTE;tb+w@~C2K=}7t(W?(a%Ea=7= z={xJXuX(2uHR3zt7RFAS@NBPk=q&z;JMF^TuX`-ckLH@j_sU>*SlfJ0t(g*B`4PLG zR=GQB?3v#X^Ng$ibe@u!<&Ee=f;aUi{^@MV?7enm=l<3%`NhKTry2xsp0AzV<8ajS zu0`(ZsMM`Vq0`R)D^KS?&ej&o$-Jv=Urs1I`yyVO6 zFsBJ-3ae(63tMg1xge=@!}W*kN{utuQ`R>!rtFc{FAe@8>!Q!uFJ7^Xuj^BGxWw!e zCd{RrS??0>TokyzdCRqC&s!dR8Zj!bemz~1SP<10zD}UqP%%vRkgiw8-Ye{W{r8UC zyr`QkX#TJxZ_86py94*Vm}K%3lZ78nc3ZN0$+wElQ7={t%}zX{;Zy53>8Po`Z_r^p-T{&m-nDtrBE`gCK?HoG~N z)+OwdR_49dO=4^9NHh6Z@x}gXm*vsy&8g`x4qmuF;c#?g`_i*YhZk=zU&L1}-P`gq zx^SacXv%}ymR{*?ocyteb5AYf)ahQ}#IUVh{?MQ0W(oya&wV6o_)o}r9Q{~hRJG2# z=V1J;<=PTq9CaH6!bIZcZTAZ=dmIqm^f2}BR9b7N=AymC?A<3z*At-}FKl-z``!$>DYJf~`FKwf5$xt)--^mL$#2$O*kmKevpnDOVk^U4ok}$RIZX&Z`%cFmLSO0tW`w>&1bmI5v=b9!IAF0?{VR!3V z!F;g?rAxEKW(i3yUR3=|S^tyYvB-yYESmf0S#i{q3ELiZvK22hd1>Hbz$3i$@zg1^mA7`THQ6MQvF7X)g|o#Q z4dS&+a{rw3X1U~YsAJDp2dmeMIT!yr{-!uc_PXdPpP~)1oBgB{XCc~D^$ETbNtydDdNXe=k6)%7wx;4Hb=Zl zyQ7uoxO(Karp#Lce@%8dFZi7ma>SbX>ps1tBBuWineX0jaGPeb*vNm@E4O|jH6f$S z#n#Pt*rjhzu8-OL(afHU<+b|ttCLw2SJm^Devf$9J>6D-udYt!g#pX5NmdKD{5p{E zd{-`iU+1ZRx=O7}rfc)*ZQ~8*^RQ?DtQ?_>9f|?IEl)izBy08xV_6r zdcrXw_N;bR#TD0IM4WJ(eKa>*@k5hWmj9|xhT*HW`dR9KWnf-%`7HkLFLvf>UALRiVqtM5MSh85e$NA~ ziuP>Nbr&m7x(ilc=%4pVg;|(1r0L-IQSPd628mp>h-eoU5)Aenpv;EncZ!$-|?YQ z{dvO-{f<4eMfbZ;HkDW~@m-rvdHa&`t^;3r>tfewY8(}7Jz9AB*|J@)#`x@hP6y?-qg@2x!g_5b`Y Vnfeurf?qQI-ZAUE^Rs;=%m5*JZlVAH literal 1690 zcmcckVH3jx9iNA9k~9ypCQ0-yUH_?f&6UL3SzeV}>b7u&Ef2VC6V4{-!M1Ow>+M-# zaZxtcmNfZX&UdKX7`!cwe{#syhqABE$a5|$k&!RBQ?qNcl%w2@!jqm8!{?f7Vsea)l`)2#A&e5=mN96Rwth>v$wc&+2ysT;#Piz242 zcUm8y|G8uHJyXrIzu)XG5N-FKawh+FjZMv~+BdbU>}Q`7Sjn5{RH0g&ecf2B<=E@> z4}SNoSo`+B;+B73GdRmTtkUbgJT%I~)(E-@fONvb)w|0Cn!W0eWndC_jm zd_MP!UXVEYCL!`P^W}~5CJXn5MG0{xm|ZMevAXKj`2f`={%v1ZFf;A!Su?HwLecY8 z))uX;suB?{%Vv5Q-d-?S>eSD^jK#hmi(AaB-@pDF_wcag0YAmK@9D*S9UVo@Q`dYt z_{p^R_JQdcT=fZ?+3(05`zAeQ^>T-oJKS&iosZZq|6l&Ed2W>3vnfxHD$iTAZRNFP zd>vZnS6`iLEq0feT$S!__|2yKVuzMt-O8)Imme;W-6yEU@Ich< zT!xNNkJeY87-!oac5SUqd69P}pG%ysJj49o@?P`5iweKHx<9*jd~3<6oYHHTIrepD zZA*Mvw13vjAMXCn5{I6tw;cTbXMdlKOU)j~|}|b9~v$>`SF#WLyvyUztZ|m$4UO}{1YpG-<{4d(^2JOd9BFX zDt}$?4C@oYo2LD*m~d>n;%coXX!gOT8efG zd}|8QWLufyAai=7_Qaybo{qwmLfuPB+#F_#KjFx07r81jWm8M!qz*O@wdkF@E{V-j z&D$#_-PZi3>zM8i;rOcp+utscun@eHV9nXPq%rpImwe6hnJTZ{w@sfXEsd9h5k@W-Rp{tL+mCu%Y6-nra#+RE5%g 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 .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","@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 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 .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 d7479672cf281dfbba69bb5d09034c9262bf0692..2fda63037f8da7e97d557de0283fb3148b4aa42e 100644 GIT binary patch literal 2117 zcmdPcs{c17UVN`3gH@OfzlMOX3;%{i7v^{9YchQg-2B^U(=8bVpW5v*h20x(EuJrM zx|@%|p-V&HA;U9<=L~Cay>I)xS$g+GeWzvdFXn$&k?%er9vP*-JY~P>n_UIg@ouk+ z1Dq~frGC%3rg#3%oS9qWM{$}&mV3{kYHkdlt z+|Qe&=-#u*c@495(heKfr_oRTEMK%cLFQZV+&a`Zev|R^TpDM$5^+{nN+(}&LZw_ z=>K2OJES)0^w{5h`nJYxjbeODc(|$Z>%T&qm)3g8ZcJ_XyY6jgdCS8_zQuPM_QaPZ zf7Lx1kX@e_%Bw0}>vi(VoZV{dD_*tl{T%*&ey~x&j&;&*+)iFeB0}dII#zuuG%46| z&dcJ=?LvPxK7l6-6F#kNt2t-bDsp0RqE2wO1V>ovk_AUp)hloIuB~e`+4Y>a-6&$| zyyW=TS`#+O9bGVw#PEdQNV;yI2Mws&V-yZT>^V~L6h_mT3c{l4c*F0#+=b+DWs ze!?;MA8X9@TYlp9&)M2fCy25iQos9N%d^4L^2)rtIpdtVgqZK#$%@4Dppl%0kDetYwZ z&$X|2Rmh#cfH^cIQMx}WvD(k=)z;6?UBr(@u;0IOExcZ6F&p2und&R0f)tM0T4VEfX>VPaF0$Pxn? zXN5`b%$h8defqEDn9dS;WO1F>YKOP$-c`%{3&MKt&5}vioIOeWi1U1&-v9k7r=#+3 z-n5zFbkSz!{V4}xcQd?cs8gM$Fu|2S{L5M^%TLQ@1Sw8>Zj^H2ds4B$&Yrr5FT#U7 z6wd1{?Kl%NY1Vex19O=L?y)gFI_PoXue98aiRaflG-$tISjPD2;F+7Tzl8o7+dkT8 zxzhCI&bgjqRcZ(S{5;TdaV49~n~Ao8{0k>1w>sZoZ{(bwXuUCQhpSOfh3w*ntqmSY z6aGDNc=|sqtG)X{xJ=$9vkCix0&V9>{4P5wadY0I)eSsJf81B^JbSo6A>46Chu&x9 zW9vEA=pKuoxySd}2BoZJFPip0yHnSyeUiu3tR+xgTVy(8(}C8#oR=@Z4h~ZC{I=53 z>_M|?dU12{@#c4}C)sY^4D{W4VNa0cVc{=c8Zn%u(Pzb~ZiI;6*d^52w&>WVV`hTw zDp6^Q4)04D!=mKtKFiPc>Aq%=ruA`_%j$wa;};$3GXqxDo9eipYP-4a?w?5}=^Jlu zRM_w&qFTyh;j(vJk!6mz4Pzbpo7Db<@fNC1F86zT_vhC59e!L&yRzw6U)f0w zBcFg~1%{Q!<&*+0Z0&iGHtV1TyR^;m*_C&<7oO-{@bpPo&vVtNyaHXHJo~?k1$ho6 zYYW;g=6|$cH}fAYp}+&d)BaxOTwoblHE)LS@|T)#40129eVn;*LyOnymdxCj%jU70 z>m;yzm~hzr{}!3M(Z6bVk`0`>wsWo)cyCkL^EcsF`q5P38)>b#0l%$s{T+rQpV3f4|L-f~dQ{D$+E`+r+`Z$2&A(01>_xOdrv)kG( zSF}8sVgIA-58K&RT>sU2ASsYt=)>z-N$a->t^KOLaiQY-Ij^EhYrU^L;H*5XFCn0; z{GK^U{MNkUB!;D%7WtIM-VoO>Jiqrsp2;27{xh>RPfXk(e4$?Pk~qiezt0^{D980) zik%g_Gg?yUdtu!5tBbt^%x>qZn!n$BX;F#8{E|y+YyM{zUgO)?+h}EAqHX(IQE492 zcDD+hI-#uZbw-VH>*ogD<4#lMZt?55`{;jB?!J;$t7RFERx>Bf4U4FaDU7Q9zAfZP z?~Q1ygiiaq&j*f8uT6TzzF>h<(42h}7E~-ez*DnR+?OXkKl|T`6Dy2l41YMxZ$JFA y#r{v@&yy!pSeIYPT%>&4=P%>AJ#QPEt_!QYd?WEJCUKwo^X#TWDWU152igI{*Z%42HEpO)Y?l!t}Zu-SvbEZ77 z;9q=i?}^gP)#Lsxsyel?uG>>OzZOyUxx$8}eOik?shr=&<6G~;DrL0$- zf7mkWxx|YZ{~{eLws1=OR6nf{cAm+4R6~Pp(Tw&awLG)D7Rl*XioCvUt$I-L)%(}= z{qBpEPapbmCHCIA^k&N^-5*{}I@9*`U#H|{yXAQXZU^hGmmRG>birZcf8CcA6P8~4 zcW08xoSZL~njyvSR>`P%I=`8_zsjGL?|SLsV@(@oNx3owWrT#<^|A7OkL+x(w9Z>~ zz_H6g%xB-z3Fj@BT@q@v;AzX^V?Ma5XX=t4Uo`Z0Z@hYbg4_2y9|PD{UoC!_~?~k58oH3dN#N7k6wlINemxrs2O>#f4+l!Q(DE?Y##BH>_J>TG)5~Wz?m^H#H}e z=B&D!TJM*>P22q4Uj3i7mwqrEQNQUHWo0qbmCIg-m}7VgZOnjL?& zI%_#c>-^Ul54^0BxDUNtdn>=4J!H3Ome%%p=a(A%J-_xu%LLKZz!}w|eTW*q^*4U;>*0fo-@zC3f+8f zg6GEO#TIu}tj(9^)UFdR(0{V?oH!pKFy*}~FVGV1Q_tWo2@G=N- zaXr0Z(is)KsP6hHP9_c!odr{bOFn0Xxi&a49oXBG!DzVZu?3S#W}|{?(L3LQMf39# zJH+}oeYnxKCx6eHCl=g1t+_MGEb>guyUt#45W02Y!iB&6T?R_6u_3ogww;<#xn)C# z+)<_|1(vTy4+9;(J@|I4D#zTaxO^3$xG0^N2q6thrpz!NltC1&I*&}Uu%@QJTLqdXJwLM+V$t_ z+7_k%)R_4zDO&IS$(nz?&rh-**E+deCY^cV&YIS|Wpgs)9yNTi`uOl|w!r(gz(Zd@ z99YoKwdd%Ju-1)7mOF}Z$6eyMIFrjD?96&DK6jA-nJ=3jT>YxLhHDwajv}5r2ZGOW zny+eZdNALcZKVSDooUk7DjC!m&rkG-^8dVGTlCiT(qC6J)<0|!3E+_Vnr2~>9zV13)B;%r>G^^`zG(!$#xw(Or`XOnu-=JVC} zRcdcKUKyCO$hEV2e|Q}<@0oyIb64&9e&5sptcs7)-NAEX+7w>4&l^6o zt+Lf~Sy*-{8`YEHsjl@nrzn}pbXCRJaKyWhRnnq|eztCPyy zdN=4WcipgKvDzTP8~A;8WQuvKK{jYDhiuRY@ zQtCOsc-FTk8I6XYoq}T?M|dx(VB}J|z9{C`#aAN7HXh&a6>fB}Xk(^>#{rQIt79V~ey7b%nw}MgsDziS6 zeSb5*xM5u@bBk#AF73u0S42v-PGE8Vzu3pO!_TF>+q-ZPfB&mqZ-XgkGUQJ8DLRO* z*Vr5_e)j{jaH?fxx2YQs4~tQwrdB}p-P#k%3R6yRz0x*E!EfH`Pb;H^qL{w+R{Oo% zBeU|mV}X@*&dC|>4>A-*6auUEiEiA>*q6>;^LVOk#H{?;_qVumA24weH*Zvu6FMpz z?-KCg>*wkD59>X=5-cY2*?6ow>%A^(;zrT^r?=}IY)Lgaz2xewn_{2uT-g&UzIv6+ z#Nr2hXE^0$6+ZCad|un^$l!G9^SSP*EpZm>Wj{}Me0JEu!Jx^Gn|%X|`nhI*)khtw pR;~AJobLUoH8IKDx^Y>t(x#U>51+@x+x<-?cd-{V z9xmay%cxTOM54v=;h&j3X2Po*470v}xc{p3n8JJBMnAbt?++<1zdK>m!NeB7mkSnF z|6Q1|R7OFha!skC)gwj4;&KUrTpd&%gWQ)5p?(KJ|W|AJ{A2T(S92`I0@~ z4zK$D>eH>YRa~;!7Yq%9RwS+zcv_NltW(`kLHXu_$y+^E{*Jj|V*D!O*qv4GCA_nC z{1mbLe1Deh+P`-vUOT=yyy%~Xx4qSBvAw${Jlkw?@$|cVp;A%4?*Cug4{g-z7mmF! z<@X1xw(#^90a`Wsva9#2n!BBn+3vse!Jaw+^~tw z?`zMsZg<>zLeB4nOzGXmIm~7+XK2XG74h`@eeA1{Z&SjH%WD^`4^m%x=b2D)i{HmX1Ow;BtC8Lxl6 z(bex;*drH)N9(b71QQ#5PKh&{F?QkNWk_t@t(EXsb=PDuGkkx zn}2w^JS5_d)ffMye4fRb%QT+yPV6>6%&J-NW!p*lJ9T+7hm>WKBKO*-eOmt05i`{mZ&$*0-B>9kXvQpHetynM`y!KjY(|KSdU+ z)e;v^n7_?+v*?5)Mk_bE|NQx;Z{D2^Gd8sUUapy$(J=Gvn>y|DF$(kl_3b@5O?9Ql z-^c2w9yQep9gC9J^0jfh+VUiAwUkzEeYalEts6_D7~d>@#`Ju;0Z;Ty#Rrb3t==lC ziAnMopX*!XZ8=$Pu6WOpWg>P@57thf{;FmDzLM3&ZJQDnu4;2sGP{58+B<^;wbM_g zp4y(Cy?9;u3Q@-GYlEWPoZd2P{q(I9cq`aLN;uvq= zR@?N*?XNhK|2i|H#J*{KT%Q-N=9TH~KVi43$Z*bwGFfj`Ki=9m=~HT=*S@gXCE0l( zqs>f;yHfPcnq6!xiXSA>CC}!S#XL9>;M$Q^=x(xif^f<*&ute3|36cT%3S4>V&8Jb zf_K(|puIm=DyCJ~9NP5N@l@6d>qFnlm=?F~{h1{wNiA#7*m8bAe~hZ0VQ>)=c4bHL3SytQ~~B f-(HBiF1=V?K>6gmU!Ojj*6%w1R=ix~S~vp$sqctP literal 1269 zcmX?@QO}@oJ?L%y{ONl$-$(?kGbyQFTBftMYjOkIwUZSlKmNa)9?BOTzs)&qu zlkF*KUJc!zi~*&sktY?-a6J#-6*}>(_MIidxe@x#>6)!2<$NV~HGk^aKi^_$s5ytb z#EaYZ)tlgHu1fq1Pb9y!^*-v{^n9Ae6`#$UCtmj0C%eD!?djv!e{OzQEW`F+(@!h% zY`xoDHS_+p-e$gKIi)XVIJDndmOWycTTfMWW=~x+lhb-z z$F|0f)fXpj{=4?p=O_7Mwz=2WSA5qfjGi01Rqy5=xpS9pO)IZ|q_Eij?7QpoZ?_xC zER)hV{CnuNoL=P7faQm_KMK3UyDw}@z#Y5kO}C}QD_)+vP#kow@~HLk*16&Otf?L! zlLReiyek&mb4J(grt+6~_p??HWfSK1ZEak%S>c=Oqqrv)vm_2KzHD0Bf3<4z{)$9~ z^**f`IjeyF42NW)=qDV%+0v>T0*@;qJ;WFCPaRFKqeD_qCyO&i`G> zLGHf0f5dxOUVCz7W|e6EAIn3T+>@H~E?XZ863DeVS-AG*{O8+V8x_1hBo!xS&9kUm z+A+nEPf&YN`DDKYgHO-EZEmJ<=(&VgK2uuaApcU%J+*^*XtzRl46gymp!w(8!Knzx^CdiITd`SRm)1aAgPzt^~>x9nu`kAg+J zUruk9+9GE1`hxM4?0Hq<5@pkwuh>0k?RZyZ_kM17?(Ju5d!=`sSgG;3;lbXw7nuu|%wT>r8&14Qpf>x0V;|doKF0_w&mcmqON>8g}+6s>)2NpMT(m)_aEQ zn{Fk4&F*C2tps!;>F&ApY~O4BnZ2uLA7s}0ZpQFzP22ZE5#Q~a{`sdrKJVJ~ zzm+fIg68)7DPbN*4;B5Q0>elb=`&SfK-J0IN{-i%!dj2Nc&zDsJJ+Fji diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst index e9a5163aae326ac383a688a01ab7bc44f4cdf38d..f9a711706ea4fb17f9545e0c604f4eb06253078a 100644 GIT binary patch literal 1502 zcmdPcs{c1-K7Xqo1DCS~kJ<%^=Zp`WZ+X=3vP~~VX7x|$T6uI`gtDss zo(EoX27Fr>b}?*cSh()d5{2vMCq6nOvQDp6C~D!aGiB-fL}qVmzx}+T_LxwoN2%ud z^>>XH2QFQDJ29Q_Cf~=G-M(#^H8YaRyit>BYrrJjEF88bF@9FDjY2CMH<*eY@LHm?Awtcy?w#;tZ z#q)0k?#sM7m%XX|q5J(U|NZ=`MP5HI;`lP>cK^)(Yt5S8rAjIG&Ixwl{a`7sFH)sz zc71;Cvj5h5m?nk&KJqW+XD@Hphw|kQ9?xAE5Wjn}tM=+;6L;RY;3Fg}ClKs0Icn?m zBSPv&4e~-S`^5iV-I}rBRh9QU@2tsd*S^v&nsTpHK09vJFOS_5XB|s!UL1NU?!N4V zD>~PM(%m;L-Kkv{ttWEn#)l0Lo_DOb&0J!)?POZ>|8MrqtM*+@+CKkqNW~4q-rBfD zCaXo+?w!t_Z?|*$#tvn_x##a~wG1rIyK6l0_^+?$ndg}5>(4tn`=s!54y%LvErb{U zo4f1sb#9X%XD41(*57dCl3KH1c}{(>tjqCTu8z$0vXN%1*4DoGw5Ym$n%E(2l}@E^ zk`hO_o%hHbcyT9c`tiJnum5bgv|7Hbyn#nMXOgrtg(zP|1e|K!1UutUCw>LYBp1pmyYIpSOvYREFL@l?N?sYVH^(5k! z$;{3bRo~t&c&-wC%P;cQ_2nOIH(udpkSJe~?c~VwxTkO4O=->u9R~f9g`2w1$d&pA zFwc3{HK#B;Ft~V%Rm_X0IbYWtZrJnx$jX|`GtoCq=grfc{>>zLb*jisY11>SzXbbd zZF--3Ty5St-Po54ZC^xHw)U(PVEe+V$Y8XluVKN}XI6`OK2_$Qv2wrcfBDQ*gYEIv zp?fArXFV?2ySD6%sk_60;Id<)^R4w<|7&Y6ys+AU*XjGjTb)yUD#Lr$&z*E|(|hTx zx&MCeiSCtQ>@ zH9QhiBbY@e?7VpDo&aBg!olvD)~2qk7Fyl*2Gdv1oG!rjKAUr|+trm{^-WIXGhGc} z4LyCGsi^DizDH77t@mChwr#(h{bS1;`7)?SZo$ZczW+<=WpZ7D?5yfLh3*L(q(=Bvf|Wp zH+IR-GcTG3Dpf4aS8}W1Zf*DOXsF8R%Kv^itFI@@=rPlkh6pa^w(xBxCAZG8>OA!O z&6>ioHm_A_&Roxlw22mZ&`B@h6F|$HTw{4Z}|Fe{Djo^)CnV)m#B!}Fw;qc+- zpL&&3SgP%MwEM=V*1Id+>U~4A6Q=I?+JE(cpjP>28R^51l#WG-U7d9^+k2j{)VApt zxkLVG%;5Wc>dfLr5B)E?epX}rv_dR|eWq2LK&X5Em3*BEl0T$WzWmV*PYPKO_gKqr ziRaJmDL~*ANz0nk2v)WVc$*D_pr*ncl$1*(y>i8zw~mK+J@6vPY&Pkie2TW zcuX(w(-B39M49XEwRW|ZLJfKL6OZUg&)sDGi|54D)ZbPoOTKSoU?%*y-XK6gb< z<~!N#WR19CHR0xhzjqauo;k>Rqxm!2=JK6eH`^b4G5u@CnFo)r9F1$NSSlGdt+_%X zwr1^=$%fC&S+{3i?+i9bFWW|w_0A!@4t g54SN7o0rnsRSFM+XZ{pE7^bc?ontjq#v{{-0GzeyWdHyG literal 1475 zcmdPcs{c3T9p74A1}V1{JlY3@1Z@{EUGPuc-{QLFZ^8As3!?(r8Ll2IIyR^LKI4J% z&>PXrjsh%>O)@OI84fY*Ww^YZ|9}$bR&M(MKkmozFH|fef1P!0;g}${W;L z@}_CWeUtrG*PLpq->ErI^?6a%v!_AdOh4QFjP<(1vqbcM_tK5)_*d607md18e#m_0 zy-wGI**&b&jy<||^6jf9FV$a5Ufo)@a|^5ZWi zbM>i7T-ScA>1t=;?zp^wiLJFc(X>14{e_02peyUr4MQ6({C{RHjhJ?P_Tx`ibe3QG z`hi8-nUooJSUSY(k(t=+2gOJ9rgL%kaQr^~K=U-` zUFUZK8*h~Vi$8tXPy6Ye8O%(nw-1Iqoo%W8%i>?)inUznJM@q0Ca@yHosQ@5~iOyTjtOuFUz%a@_0UZ?g)vn@UPDUJn8qpRdVg z*?LOAW~x}qy-n2LWR#riVc9Ln(;Y@=z zYt6jt{nPV*OTM&wu&!>U%G{aqX>r{@mHl@L8XugPv(KQ>YvnoCN{fVy@bj_^E2dh! ze=yHQ)m4|ZEjBcr#YBqX7Hg4+$ja{7v!*gWZ4t1#Fz3c)yaAIS!na;zMwD$YGs5eeeXY0S3HEDj< za;nsbt zdUWCGq^OsNH-CDkZxmw zc-_iicqI7!wZwz^jn0<*t6K`TOfBr0XMI^!r`P!V*SjBEt{tw7k8;lNT_Powstz2U-MM(CfCYc=Qr~dAGmXoOYm;x zV%EY-C;u-wcwg(wEz#!OS4YDqx=4BL->{)3`d@VAp(}HZw>89UPrG{}<6H!HMaql) z>Y0v{qF2q;I$Lt@l1^A%V`JEXtDSCZ#X5r(54`wt)KE(X_oHW;-=GB>KY0qeW=n%VedFNKn zzps9`+U+u)60MaG*IRLxX-zEGA?IBZ&YkIQulIGX?Jd#mV?H~neD0$o&$y>PoPX5* zxqS1*dfp?&g;SRX79C{f3Q-f+b=*@@Vr%}o);>G`fc)yuRbpH#q+LAzF`sYv?`%}2 zZPYBNyy{qORjZy^>k5b0wmi(9V(JqfO3a@0+A@ekNmD9u`ri%jA01oko_zHHbI&5p yX4`G83JX}muTA-37t_LI-qXGB8ROh^gO`c|TIx=H`pPREe4a|}$=aebcN+lWFTUvj diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css index a0db29e5..7bf0ad02 100644 --- a/resources/css/admin-tokens.css +++ b/resources/css/admin-tokens.css @@ -134,4 +134,30 @@ .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/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 index c9443e29..33806cdd 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -4,6 +4,15 @@ @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 diff --git a/routes/web.php b/routes/web.php index 03d865b1..a9b46407 100644 --- a/routes/web.php +++ b/routes/web.php @@ -157,6 +157,8 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { // 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']); }); diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php index 0c415296..d6695a15 100644 --- a/tests/Feature/Admin/TokensTest.php +++ b/tests/Feature/Admin/TokensTest.php @@ -37,6 +37,73 @@ class TokensTest extends TestCase $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 { From 77998a963e57c1a6557f448f7fd1a6376094e858 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 13 Sep 2026 11:42:28 +0100 Subject: [PATCH 22/25] Fix relative Location header when Micropub creates an article EntryHandler used Article::link, which is deliberately a site-relative path elsewhere in the app, directly as the Micropub response's Location URL. Every other post type it returns (notes, bookmarks, places) already prepends the site URL, so articles were the only case where clients received a relative Location - iA Writer appears to treat that as a local file path and fails to open it after posting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy --- app/Services/Micropub/Handlers/EntryHandler.php | 2 +- tests/Feature/MicropubControllerTest.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index 48bbb550..a19f4d2b 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -37,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']) => config('app.url').resolve(ArticleService::class)->create($dataArray)->link, default => resolve(NoteService::class)->create($dataArray)->uri, }; diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index efcfb6ce..e1795561 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -871,6 +871,8 @@ class MicropubControllerTest extends TestCase 'main' => $content, 'published' => true, ]); + $response->assertHeader('Location'); + $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location')); } #[Test] From 4aa93d63bbee4cab51ae4bd2bbe5f4448ee4bc77 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 13 Sep 2026 12:12:36 +0100 Subject: [PATCH 23/25] Add Article::uri accessor for the absolute post URL Follow the uri/link convention already used by Note, Bookmark, and Place, rather than concatenating config('app.url') inline where the absolute URL is needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy --- app/Models/Article.php | 7 +++++++ app/Services/Micropub/Handlers/EntryHandler.php | 2 +- tests/Unit/ArticlesTest.php | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index ab0602d1..9ac2335d 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -93,6 +93,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/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index a19f4d2b..d79a0418 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -37,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']) => config('app.url').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/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index fda1abf4..0de3277d 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -63,6 +63,17 @@ 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 date_scope_returns_expected_articles(): void { From 6727138f43f752e095da074e365693b810f0e8c2 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 19 Sep 2026 12:22:33 +0100 Subject: [PATCH 24/25] Report exceptions from Micropub 500 error paths instead of swallowing them MicropubController's catch-all handlers returned a generic 500 without ever calling report(), so failures never reached laravel.log or Flare (Flare is already wired up via bootstrap/app.php). Widened the final catch to \Throwable so PHP Errors (e.g. TypeError) get the same Micropub-shaped error response and are also reported. Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/MicropubController.php | 12 +++++++++--- tests/Feature/MicropubControllerTest.php | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 2df5d432..72242150 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -70,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', @@ -80,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', diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index e1795561..86ffa972 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; @@ -12,6 +13,7 @@ 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 +459,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', [ From eb35a0aa2d4fef3b84e8653f9d1a0e7bf8dbcde1 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 19 Sep 2026 17:16:11 +0100 Subject: [PATCH 25/25] Update existing draft articles instead of erroring on a repeat Micropub post If a Micropub h-entry post's title matches an existing article that's still a draft, update that article in place rather than trying to insert a duplicate. If it matches one that's already published, reject the request with a clear error instead of silently colliding. Also set includeTrashed on Article's slug config as a safety net: this model soft-deletes, and Sluggable's uniqueness check ignores trashed rows by default, so a previously-deleted article's title could crash new inserts with a raw unique constraint violation (this is exactly what surfaced in Flare as a UniqueConstraintViolationException on articles_titleurl_unique once the prior swallowed-exception fix shipped). Co-Authored-By: Claude Sonnet 5 --- app/Models/Article.php | 1 + app/Services/ArticleService.php | 21 +++++++- tests/Feature/MicropubControllerTest.php | 61 ++++++++++++++++++++++++ tests/Unit/ArticlesTest.php | 11 +++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index 9ac2335d..330ff03e 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -40,6 +40,7 @@ class Article extends Model return [ 'titleurl' => [ 'source' => 'title', + 'includeTrashed' => true, ], ]; } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 2372ffb7..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' => ($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/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 86ffa972..3fd8b515 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -8,6 +8,7 @@ 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; @@ -911,4 +912,64 @@ class MicropubControllerTest extends TestCase '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/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index 0de3277d..afcc0828 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -74,6 +74,17 @@ class ArticlesTest extends TestCase $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 {