Compare commits

...
Author SHA1 Message Date
eb35a0aa2d
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 <noreply@anthropic.com>
2026-09-19 17:16:11 +01:00
6727138f43
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 <noreply@anthropic.com>
2026-09-19 12:22:33 +01:00
4aa93d63bb
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 12:12:36 +01:00
77998a963e
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 11:42:28 +01:00
568ae78864
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 10:40:27 +01:00
9e9fbdc127
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bzYwaDD8p3XNDMYKvXKrs
2026-08-28 14:49:53 +01:00
9532aae37e
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113BVPXDpEk9HPEqXvSG2WQ
2026-08-23 10:17:26 +01:00
e9d8f4c74b
Redesign theme toggle as a two-icon light/dark switch
Replace the single-icon popover with a dropdown of radio inputs with a
compact two-button toggle (light/dark), inspired by vale.rocks' design:
clicking an icon forces that theme, clicking the active icon again
returns to following the system preference. Keeps the existing
light-dark() colour-scheme mechanism and view-transition wipe
animation, and reuses the project's existing Fluent UI sun/moon icons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fn4aNjE3ofpDM78F4qmdCV
2026-08-22 18:14:08 +01:00
be2bc267ad
Remove snow-fall 2026-08-22 17:39:31 +01:00
a76c84b40f
Respect Micropub post-status when creating articles
Articles created via Micropub were always published immediately,
ignoring the client-supplied post-status property. Clients like iA
Writer that publish drafts (post-status: draft) had no way to land
an article as unpublished.

EntryData now extracts post-status from the request, and
ArticleService sets published accordingly, defaulting to true when
the property is omitted or set to anything other than "draft".

Fixes #123

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8
2026-08-22 17:16:55 +01:00
99f306ede1
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8
2026-08-22 10:42:23 +01:00
714cd95d79
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8
2026-08-22 10:42:16 +01:00
6a0bb623fc
Restyle the Micropub tokens admin table
Breaks the table out of the centred content column to full page
width via a subgrid on <main> (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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHN7V9kyZxrsxaspMSqoGJ
2026-08-21 16:05:48 +01:00
30ec2ec0e8
Initial work on better admin page styling 2026-08-21 15:36:18 +01:00
4ed98c30b2
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHN7V9kyZxrsxaspMSqoGJ
2026-08-21 15:32:09 +01:00
faf8e5c1ec
Fix CSRF exemption and array-input crash on revocation/introspection
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 17:03:43 +01:00
24da24a677
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 16:44:31 +01:00
9d6cf6c815
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 16:18:52 +01:00
9c9a6392c8
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 16:13:11 +01:00
d5706b5f8f
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 16:07:07 +01:00
f9f2744fad
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.
2026-08-02 18:07:39 +01:00
31c49ac3fc
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Sorsgn85nw7uQyRMNvzyD
2026-08-01 17:07:38 +01:00
287520ad7b
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5TeZbk9KS754xuJMobjE
2026-07-28 19:20:18 +01:00
2eab5749ea
Update dependencies 2026-07-26 10:57:20 +01:00
e08763c526
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8j7cJhCiYDsGUgB7obKNQ
2026-07-26 09:12:01 +01:00
103 changed files with 3139 additions and 1995 deletions

View file

@ -78,6 +78,8 @@ SESSION_SAME_SITE=strict
LOG_SLACK_WEBHOOK_URL= LOG_SLACK_WEBHOOK_URL=
BRRR_WEBHOOK_URL=
FLARE_KEY= FLARE_KEY=
IGNITION_OPEN_AI_KEY= IGNITION_OPEN_AI_KEY=

View file

@ -6,9 +6,9 @@ namespace App\Console\Commands;
use App\Models\Media; use App\Models\Media;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Image\ImageException;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Exceptions\DecoderException;
use Intervention\Image\ImageManager;
class ReprocessMediaImages extends Command 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'; 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') $media = Media::where('type', 'image')
->whereNotNull('image_widths') ->whereNotNull('image_widths')
@ -44,10 +44,10 @@ class ReprocessMediaImages extends Command
$this->info("Processing: {$path}"); $this->info("Processing: {$path}");
$image = Image::fromStorage($path, 'public');
try { try {
$file = Storage::disk('public')->get($path); $image->width();
$image = $manager->read($file); } catch (ImageException) {
} catch (DecoderException) {
$this->warn(' Could not decode image, skipping.'); $this->warn(' Could not decode image, skipping.');
continue; continue;
@ -57,11 +57,8 @@ class ReprocessMediaImages extends Command
$extension = array_pop($filenameParts); $extension = array_pop($filenameParts);
$basename = trim(implode('.', $filenameParts), '.'); $basename = trim(implode('.', $filenameParts), '.');
$medium = $image->scale(width: 1000); Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes());
Storage::disk('public')->put($basename.'-medium.'.$extension, (string) $medium->encode()); Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes());
$small = $image->scale(width: 500);
Storage::disk('public')->put($basename.'-small.'.$extension, (string) $small->encode());
$this->info(' Done.'); $this->info(' Done.');
} }

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use App\Models\About;
use Illuminate\View\View;
class AboutPageController extends Controller
{
public function show(): View
{
return view('about', [
'about' => About::first()?->content,
]);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\About;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AboutController extends Controller
{
public function show(): View
{
$about = About::first();
return view('admin.about.show', [
'aboutEntry' => $about,
]);
}
public function update(Request $request): RedirectResponse
{
$about = About::firstOrNew();
$about->content = $request->input('content');
$about->save();
return redirect()->route('admin.about.show');
}
}

View file

@ -6,11 +6,11 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Contact; use App\Models\Contact;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View; use Illuminate\View\View;
class ContactsController extends Controller class ContactsController extends Controller
@ -113,14 +113,13 @@ class ContactsController extends Controller
$avatar = null; $avatar = null;
$contact = Contact::findOrFail($contactId); $contact = Contact::findOrFail($contactId);
if ($contact->homepage !== null && mb_strlen($contact->homepage) !== 0) { if ($contact->homepage !== null && mb_strlen($contact->homepage) !== 0) {
$client = resolve(Client::class);
try { try {
$response = $client->get($contact->homepage); $response = Http::throw()->get($contact->homepage);
} catch (BadResponseException $e) { } catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit') return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Bad resposne from contacts homepage'); ->with('error', 'Bad resposne from contacts homepage');
} }
$mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage); $mf2 = \Mf2\parse($response->body(), $contact->homepage);
foreach ($mf2['items'] as $microformat) { foreach ($mf2['items'] as $microformat) {
if (Arr::get($microformat, 'type.0') === 'h-card') { if (Arr::get($microformat, 'type.0') === 'h-card') {
$avatarURL = Arr::get($microformat, 'properties.photo.0.value'); $avatarURL = Arr::get($microformat, 'properties.photo.0.value');
@ -129,8 +128,8 @@ class ContactsController extends Controller
} }
if ($avatarURL !== null) { if ($avatarURL !== null) {
try { try {
$avatar = $client->get($avatarURL); $avatar = Http::throw()->get($avatarURL);
} catch (BadResponseException $e) { } catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit') return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Unable to download avatar'); ->with('error', 'Unable to download avatar');
} }
@ -141,7 +140,7 @@ class ContactsController extends Controller
if ($filesystem->isDirectory($directory) === false) { if ($filesystem->isDirectory($directory) === false) {
$filesystem->makeDirectory($directory); $filesystem->makeDirectory($directory);
} }
$filesystem->put($directory.'/image', $avatar->getBody()); $filesystem->put($directory.'/image', $avatar->body());
return view('admin.contacts.getavatarsuccess', [ return view('admin.contacts.getavatarsuccess', [
'homepage' => parse_url($contact->homepage, PHP_URL_HOST), 'homepage' => parse_url($contact->homepage, PHP_URL_HOST),

View file

@ -254,7 +254,7 @@ class PasskeysController extends Controller
], 400); ], 400);
} }
$passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id); $passkey = Passkey::firstWhere('passkey_id', Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId));
if (! $passkey) { if (! $passkey) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
public function show(): View
{
$settings = Setting::first();
return view('admin.settings.show', [
'settings' => $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');
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
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
{
/**
* Show a list of issued Micropub tokens.
*/
public function index(): View
{
$tokens = MicropubToken::latest()->get();
return view('admin.tokens.index', compact('tokens'));
}
/**
* Show the form to manually generate a new Micropub token.
*
* This is for clients (e.g. iA Writer) that don't support the IndieAuth
* PKCE flow and instead expect to be given a token directly.
*/
public function create(): View
{
return view('admin.tokens.create');
}
/**
* Manually generate a new Micropub token.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'client_id' => 'required|string',
'scope' => 'required|array|min:1',
]);
$token = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => $validated['client_id'],
'scope' => implode(' ', $validated['scope']),
]);
return redirect('/admin/tokens')->with('new_token', $token);
}
/**
* Revoke a Micropub token.
*/
public function revoke(MicropubToken $token): RedirectResponse
{
$token->revoke();
return redirect('/admin/tokens');
}
}

View file

@ -7,66 +7,13 @@ namespace App\Http\Controllers;
use App\Models\Article; use App\Models\Article;
use App\Models\Note; use App\Models\Note;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class FeedsController extends Controller 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. * Returns the blog JSON feed.
*/ */
public function blogJson(): array public function blogJson(): JsonResponse
{ {
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$data = [ $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. * Returns the notes JSON feed.
*/ */
public function notesJson(): array public function notesJson(): JsonResponse
{ {
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
$data = [ $data = [
@ -130,7 +79,9 @@ class FeedsController extends Controller
} }
} }
return $data; return response()->json($data, 200, [
'Content-Type' => 'application/feed+json',
]);
} }
/** /**

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MicropubToken;
use App\Services\TokenService; use App\Services\TokenService;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\Uri;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\View\View; use Illuminate\View\View;
use Random\RandomException; use Random\RandomException;
@ -25,9 +25,10 @@ class IndieAuthController extends Controller
'issuer' => config('app.url'), 'issuer' => config('app.url'),
'authorization_endpoint' => route('indieauth.start'), 'authorization_endpoint' => route('indieauth.start'),
'token_endpoint' => route('indieauth.token'), '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'], 'code_challenge_methods_supported' => ['S256'],
// 'introspection_endpoint' => route('indieauth.introspection'),
// 'introspection_endpoint_auth_methods_supported' => ['none'],
]); ]);
} }
@ -179,6 +180,50 @@ class IndieAuthController extends Controller
]); ]);
} }
/**
* Process a POST request to the IndieAuth revocation endpoint (RFC 7009).
*
* Per spec this always returns HTTP 200, whether the token was revoked,
* unknown, or already revoked, so callers can't probe token validity.
*/
public function processRevocationRequest(Request $request): JsonResponse
{
MicropubToken::findActive($request->get('token'))?->revoke();
return response()->json([], 200);
}
/**
* Process a POST request to the IndieAuth token introspection endpoint
* (RFC 7662, extended by IndieAuth to require the `me` property).
*
* The caller must itself present a currently-active token as a Bearer
* credential to use this endpoint, per spec ("MUST also require some
* form of authorization"). Per spec, an inactive token being introspected
* still gets a 200 response containing only `active: false` - no other
* information about why it's inactive is given.
*/
public function processIntrospectionRequest(Request $request): JsonResponse
{
if (! MicropubToken::findActive($request->bearerToken())) {
return response()->json([], 401);
}
$token = MicropubToken::findActive($request->get('token'));
if (! $token) {
return response()->json(['active' => false]);
}
return response()->json([
'active' => true,
'me' => $token->me,
'client_id' => $token->client_id,
'scope' => $token->scope,
'iat' => $token->created_at->timestamp,
]);
}
protected function isValidRedirectUri(string $clientId, string $redirectUri): bool protected function isValidRedirectUri(string $clientId, string $redirectUri): bool
{ {
// If client_id is not a valid URL, then it's not valid // If client_id is not a valid URL, then it's not valid
@ -199,15 +244,13 @@ class IndieAuthController extends Controller
} }
// Otherwise we need to check the redirect_uri is in the client_id's redirect_uris // Otherwise we need to check the redirect_uri is in the client_id's redirect_uris
$guzzle = resolve(Client::class);
try { try {
$clientInfo = $guzzle->get($clientId); $clientInfo = Http::throw()->get($clientId);
} catch (Exception) { } catch (\Throwable) {
return false; return false;
} }
$clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId); $clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId);
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];

View file

@ -15,7 +15,6 @@ use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Token;
class MicropubController extends Controller class MicropubController extends Controller
{ {
@ -71,7 +70,9 @@ class MicropubController extends Controller
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => 'No known note with given ID', 'error_description' => 'No known note with given ID',
], 404); ], 404);
} catch (MicropubUnsupportedModelException) { } catch (MicropubUnsupportedModelException $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'invalid', 'error' => 'invalid',
'error_description' => 'This implementation currently only supports the updating of notes', 'error_description' => 'This implementation currently only supports the updating of notes',
@ -81,12 +82,16 @@ class MicropubController extends Controller
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => $e->getMessage(), 'error_description' => $e->getMessage(),
], 400); ], 400);
} catch (MicropubHandlerException) { } catch (MicropubHandlerException $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'unsupported_operation', 'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server', 'error_description' => 'The request could not be processed by this server',
], 500); ], 500);
} catch (\Exception $e) { } catch (\Throwable $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'server_error', 'error' => 'server_error',
'error_description' => 'An error occurred processing the request', 'error_description' => 'An error occurred processing the request',
@ -135,7 +140,7 @@ class MicropubController extends Controller
} }
// the default response is just to return the token data // the default response is just to return the token data
/** @var Token $tokenData */ /** @var array $tokenData */
$tokenData = $request->input('token_data'); $tokenData = $request->input('token_data');
return response()->json([ return response()->json([

View file

@ -13,9 +13,10 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Image\ImageException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
class MicropubMediaController extends Controller class MicropubMediaController extends Controller
@ -25,9 +26,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data'); $tokenData = $request->input('token_data');
$scopes = $tokenData['scope']; $scopes = $tokenData['scope'];
if (is_string($scopes)) { $scopes = explode(' ', $scopes);
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) { if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse(); return (new MicropubResponses)->insufficientScopeResponse();
} }
@ -83,9 +82,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data'); $tokenData = $request->input('token_data');
$scopes = $tokenData['scope']; $scopes = $tokenData['scope'];
if (is_string($scopes)) { $scopes = explode(' ', $scopes);
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) { if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse(); return (new MicropubResponses)->insufficientScopeResponse();
} }
@ -111,12 +108,9 @@ class MicropubMediaController extends Controller
$filename = Storage::disk('local')->putFile('media', $file); $filename = Storage::disk('local')->putFile('media', $file);
/** @var ImageManager $manager */
$manager = resolve(ImageManager::class);
try { try {
$image = $manager->read($request->file('file')); $width = Image::fromUpload($request->file('file'))->width();
$width = $image->width(); } catch (ImageException) {
} catch (Exception) {
// not an image // not an image
$width = null; $width = null;
} }

View file

@ -17,6 +17,8 @@ class LinkHeadersMiddleware
$response->header('Link', '<'.route('indieauth.metadata').'>; rel="indieauth-metadata"', false); $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.start').'>; rel="authorization_endpoint"', false);
$response->header('Link', '<'.route('indieauth.token').'>; rel="token_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('micropub-endpoint').'>; rel="micropub"', false);
$response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false); $response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false);

View file

@ -5,13 +5,9 @@ declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses; use App\Http\Responses\MicropubResponses;
use App\Models\MicropubToken;
use Closure; use Closure;
use Illuminate\Http\Request; 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; use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken class VerifyMicropubToken
@ -39,15 +35,15 @@ class VerifyMicropubToken
], 401); ], 401);
} }
try { $token = MicropubToken::findActive($rawToken);
$tokenData = $this->validateToken($rawToken);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { if (! $token) {
$micropubResponses = new MicropubResponses; $micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); return $micropubResponses->invalidTokenResponse();
} }
if ($tokenData->claims()->has('scope') === false) { if ($token->scope === '') {
$micropubResponses = new MicropubResponses; $micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse(); return $micropubResponses->tokenHasNoScopeResponse();
@ -56,26 +52,10 @@ class VerifyMicropubToken
return $next($request->merge([ return $next($request->merge([
'access_token' => $rawToken, 'access_token' => $rawToken,
'token_data' => [ 'token_data' => [
'me' => $tokenData->claims()->get('me'), 'me' => $token->me,
'scope' => $tokenData->claims()->get('scope'), 'scope' => $token->scope,
'client_id' => $tokenData->claims()->get('client_id'), '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;
}
} }

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\FileSystem\FileSystem; use Illuminate\FileSystem\FileSystem;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class DownloadWebMention implements ShouldQueue class DownloadWebMention implements ShouldQueue
{ {
@ -29,15 +29,15 @@ class DownloadWebMention implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
* *
* @throws GuzzleException * @throws RequestException
* @throws FileNotFoundException * @throws FileNotFoundException
*/ */
public function handle(Client $guzzle): void public function handle(): void
{ {
$response = $guzzle->request('GET', $this->source); // 4XX and 5XX responses should throw so Laravel can catch and
// 4XX and 5XX responses should get Guzzle to throw an exception, // retry these automatically.
// Laravel should catch and retry these automatically. $response = Http::throw()->get($this->source);
if ($response->getStatusCode() === 200) { if ($response->status() === 200) {
$filesystem = new FileSystem; $filesystem = new FileSystem;
$filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source); $filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source);
// backup file first // backup file first
@ -56,7 +56,7 @@ class DownloadWebMention implements ShouldQueue
// save new HTML // save new HTML
$filesystem->put( $filesystem->put(
$filename, $filename,
(string) $response->getBody() $response->body()
); );
// remove backup if the same // remove backup if the same
if ($filesystem->exists($filenameBackup)) { if ($filesystem->exists($filenameBackup)) {

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\WebMention;
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 NotifyBrrrOfWebMention implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
protected WebMention $webMention
) {}
/**
* Execute the job.
*/
public function handle(): void
{
$webhookUrl = config('services.brrr.webhook_url');
if (blank($webhookUrl)) {
return;
}
Http::post($webhookUrl, [
'title' => $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',
};
}
}

View file

@ -5,14 +5,13 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use App\Models\Like; use App\Models\Like;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
@ -32,13 +31,11 @@ class ProcessLike implements ShouldQueue
/** /**
* Execute the job. * 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); $response = Http::throw()->get($this->like->url);
$mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url); $mf2 = \Mf2\parse($response->body(), $this->like->url);
if (Arr::has($mf2, 'items.0.properties.content')) { if (Arr::has($mf2, 'items.0.properties.content')) {
$this->like->content = $mf2['items'][0]['properties']['content'][0]['html']; $this->like->content = $mf2['items'][0]['properties']['content'][0]['html'];
} }

View file

@ -7,11 +7,11 @@ namespace App\Jobs;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Image\ImageException;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Exceptions\DecoderException;
use Intervention\Image\ImageManager;
class ProcessMedia implements ShouldQueue class ProcessMedia implements ShouldQueue
{ {
@ -30,15 +30,16 @@ class ProcessMedia implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*/ */
public function handle(ImageManager $manager): void public function handle(): void
{ {
// Load file // Load file
$file = Storage::disk('local')->get($this->filename); $file = Storage::disk('local')->get($this->filename);
// Open file // Open file
$image = Image::fromStorage($this->filename, 'local');
try { try {
$image = $manager->read($file); $width = $image->width();
} catch (DecoderException) { } catch (ImageException) {
// not an image; delete file and end job // not an image; delete file and end job
Storage::disk('local')->delete($this->filename); Storage::disk('local')->delete($this->filename);
@ -49,18 +50,15 @@ class ProcessMedia implements ShouldQueue
Storage::disk('public')->put($this->filename, $file); Storage::disk('public')->put($this->filename, $file);
// Create smaller versions if necessary // Create smaller versions if necessary
if ($image->width() > 1000) { if ($width > 1000) {
$filenameParts = explode('.', $this->filename); $filenameParts = explode('.', $this->filename);
$extension = array_pop($filenameParts); $extension = array_pop($filenameParts);
// the following achieves this data flow // the following achieves this data flow
// foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
$basename = trim(implode('.', $filenameParts), '.'); $basename = trim(implode('.', $filenameParts), '.');
$medium = $image->scale(width: 1000); Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes());
Storage::disk('public')->put($basename.'-medium.'.$extension, (string) $medium->encode()); Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes());
$small = $image->scale(width: 500);
Storage::disk('public')->put($basename.'-small.'.$extension, (string) $small->encode());
} }
// Now we can delete the locally saved image // Now we can delete the locally saved image

View file

@ -7,13 +7,12 @@ namespace App\Jobs;
use App\Exceptions\RemoteContentNotFoundException; use App\Exceptions\RemoteContentNotFoundException;
use App\Models\Note; use App\Models\Note;
use App\Models\WebMention; use App\Models\WebMention;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException; use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException;
use Jonnybarnes\WebmentionsParser\Parser; use Jonnybarnes\WebmentionsParser\Parser;
use Mf2; use Mf2;
@ -36,18 +35,20 @@ class ProcessWebMention implements ShouldQueue
* Execute the job. * Execute the job.
* *
* @throws RemoteContentNotFoundException * @throws RemoteContentNotFoundException
* @throws GuzzleException
* @throws InvalidMentionException * @throws InvalidMentionException
*/ */
public function handle(Parser $parser, Client $guzzle): void public function handle(Parser $parser): void
{ {
try { try {
$response = $guzzle->request('GET', $this->source); $response = Http::get($this->source);
} catch (RequestException $e) { } catch (ConnectionException) {
throw new RemoteContentNotFoundException; throw new RemoteContentNotFoundException;
} }
$this->saveRemoteContent((string) $response->getBody(), $this->source); if ($response->failed()) {
$microformats = Mf2\parse((string) $response->getBody(), $this->source); throw new RemoteContentNotFoundException;
}
$this->saveRemoteContent($response->body(), $this->source);
$microformats = Mf2\parse($response->body(), $this->source);
$webmentions = WebMention::where('source', $this->source)->get(); $webmentions = WebMention::where('source', $this->source)->get();
foreach ($webmentions as $webmention) { foreach ($webmentions as $webmention) {
// check webmention still references target // check webmention still references target
@ -95,6 +96,7 @@ class ProcessWebMention implements ShouldQueue
$webmention->type = $type; $webmention->type = $type;
$webmention->mf2 = json_encode($microformats); $webmention->mf2 = json_encode($microformats);
$webmention->save(); $webmention->save();
dispatch(new NotifyBrrrOfWebMention($webmention));
} }
/** /**

View file

@ -4,13 +4,14 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; 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) !== 'pbs.twimg.com'
&& parse_url($photo, PHP_URL_HOST) !== 'twitter.com' && parse_url($photo, PHP_URL_HOST) !== 'twitter.com'
) { ) {
$client = resolve(Client::class);
try { try {
$response = $client->get($photo); $response = Http::throw()->get($photo);
$image = $response->getBody(); $image = $response->body();
} catch (RequestException) { } catch (ConnectionException|RequestException) {
// we are opening and reading the default image so that // we are opening and reading the default image so that
$default = public_path().'/assets/profile-images/default-image'; $default = public_path().'/assets/profile-images/default-image';
$handle = fopen($default, 'rb'); $handle = fopen($default, 'rb');

View file

@ -5,14 +5,15 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use App\Models\Bookmark; use App\Models\Bookmark;
use GuzzleHttp\Client;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use JsonException;
class SaveScreenshot implements ShouldQueue class SaveScreenshot implements ShouldQueue
{ {
@ -27,77 +28,68 @@ class SaveScreenshot implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*
*
* @throws JsonException
*/ */
public function handle(): void public function handle(): void
{ {
// A normal Guzzle client $cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2')
$client = resolve(Client::class); ->withToken(config('services.cloudconvert.token'))
// A Guzzle client with a custom Middleware to retry the CloudConvert API requests ->throw();
$retryClient = resolve('RetryGuzzle');
// First request that CloudConvert takes a screenshot of the URL // First request that CloudConvert takes a screenshot of the URL
$takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [ $takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [
'headers' => [ 'url' => $this->bookmark->url,
'Authorization' => 'Bearer '.config('services.cloudconvert.token'), 'output_format' => 'png',
], 'screen_width' => 1440,
'json' => [ 'screen_height' => 900,
'url' => $this->bookmark->url, 'wait_until' => 'networkidle0',
'output_format' => 'png', 'wait_time' => 100,
'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 // Now wait till the status job is finished
$screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$taskId, [ $screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId);
'headers' => [
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
$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 // 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', [ $exportImageJob = $cloudConvert->post('/export/url', [
'headers' => [ 'input' => $finishedCaptureId,
'Authorization' => 'Bearer '.config('services.cloudconvert.token'), 'archive_multiple_files' => false,
],
'json' => [
'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 // Again, wait till the status of this export job is finished
$finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$exportImageJobId, [ $finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId);
'headers' => [
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
// Now we can download the screenshot and save it to the storage // 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->screenshot = $taskId;
$this->bookmark->save(); $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;
}
} }

View file

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use App\Models\Note; use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Header; use GuzzleHttp\Psr7\Header;
use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils; use GuzzleHttp\Psr7\Utils;
@ -14,6 +12,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Mf2\Parser; use Mf2\Parser;
@ -32,8 +31,6 @@ class SendWebMentions implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*
* @throws GuzzleException
*/ */
public function handle(): void public function handle(): void
{ {
@ -43,12 +40,9 @@ class SendWebMentions implements ShouldQueue
foreach ($urls as $url) { foreach ($urls as $url) {
$endpoint = $this->discoverWebmentionEndpoint($url); $endpoint = $this->discoverWebmentionEndpoint($url);
if ($endpoint !== null) { if ($endpoint !== null) {
$guzzle = resolve(Client::class); Http::asForm()->post($endpoint, [
$guzzle->post($endpoint, [ 'source' => $this->note->uri,
'form_params' => [ 'target' => $url,
'source' => $this->note->uri,
'target' => $url,
],
]); ]);
} }
} }
@ -56,8 +50,6 @@ class SendWebMentions implements ShouldQueue
/** /**
* Discover if a URL has a webmention endpoint. * Discover if a URL has a webmention endpoint.
*
* @throws GuzzleException
*/ */
public function discoverWebmentionEndpoint(string $url): ?string public function discoverWebmentionEndpoint(string $url): ?string
{ {
@ -71,10 +63,9 @@ class SendWebMentions implements ShouldQueue
$endpoint = null; $endpoint = null;
$guzzle = resolve(Client::class); $response = Http::get($url);
$response = $guzzle->get($url);
// check HTTP Headers for webmention endpoint // check HTTP Headers for webmention endpoint
$links = Header::parse($response->getHeader('Link')); $links = Header::parse($response->header('Link'));
foreach ($links as $link) { foreach ($links as $link) {
if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) {
return $this->resolveUri(trim($link[0], '<>'), $url); return $this->resolveUri(trim($link[0], '<>'), $url);
@ -82,7 +73,7 @@ class SendWebMentions implements ShouldQueue
} }
// failed to find a header so parse HTML // failed to find a header so parse HTML
$html = (string) $response->getBody(); $html = $response->body();
if ($html === '') { if ($html === '') {
return null; return null;

View file

@ -5,13 +5,12 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use App\Models\Note; use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SyndicateNoteToBluesky implements ShouldQueue class SyndicateNoteToBluesky implements ShouldQueue
{ {
@ -31,29 +30,18 @@ class SyndicateNoteToBluesky implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*
* @throws GuzzleException
*/ */
public function handle(Client $guzzle): void public function handle(): void
{ {
$response = $guzzle->request( // no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
'POST', $response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
'https://brid.gy/publish/webmention', 'source' => $this->note->uri,
[ 'target' => 'https://brid.gy/publish/bluesky',
'headers' => [ ]);
'Accept' => 'application/json',
],
'form_params' => [
'source' => $this->note->uri,
'target' => 'https://brid.gy/publish/bluesky',
],
'http_errors' => false,
]
);
$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->bluesky_url = $body['url'];
$this->note->save(); $this->note->save();
@ -61,7 +49,7 @@ class SyndicateNoteToBluesky implements ShouldQueue
} }
throw new \RuntimeException( throw new \RuntimeException(
'Bridgy publish to Bluesky failed: '.($body['error'] ?? (string) $response->getBody()) 'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body())
); );
} }
} }

View file

@ -5,13 +5,12 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use App\Models\Note; use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SyndicateNoteToMastodon implements ShouldQueue class SyndicateNoteToMastodon implements ShouldQueue
{ {
@ -31,29 +30,18 @@ class SyndicateNoteToMastodon implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*
* @throws GuzzleException
*/ */
public function handle(Client $guzzle): void public function handle(): void
{ {
$response = $guzzle->request( // no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
'POST', $response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
'https://brid.gy/publish/webmention', 'source' => $this->note->uri,
[ 'target' => 'https://brid.gy/publish/mastodon',
'headers' => [ ]);
'Accept' => 'application/json',
],
'form_params' => [
'source' => $this->note->uri,
'target' => 'https://brid.gy/publish/mastodon',
],
'http_errors' => false,
]
);
$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->mastodon_url = $body['url'];
$this->note->save(); $this->note->save();
@ -61,7 +49,7 @@ class SyndicateNoteToMastodon implements ShouldQueue
} }
throw new \RuntimeException( throw new \RuntimeException(
'Bridgy publish to Mastodon failed: '.($body['error'] ?? (string) $response->getBody()) 'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body())
); );
} }
} }

13
app/Models/About.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class About extends Model
{
use HasFactory;
protected $table = 'about';
}

View file

@ -40,6 +40,7 @@ class Article extends Model
return [ return [
'titleurl' => [ 'titleurl' => [
'source' => 'title', 'source' => 'title',
'includeTrashed' => true,
], ],
]; ];
} }
@ -93,6 +94,13 @@ class Article extends Model
); );
} }
protected function uri(): Attribute
{
return Attribute::get(
get: fn () => config('app.url').$this->link,
);
}
/** /**
* Scope a query to only include articles from a particular year/month. * Scope a query to only include articles from a particular year/month.
*/ */

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['token_hash', 'client_id', 'me', 'scope'])]
class MicropubToken extends Model
{
protected function casts(): array
{
return [
'revoked_at' => 'datetime',
];
}
public function revoke(): void
{
$this->forceFill(['revoked_at' => now()])->save();
}
/**
* Find the active (non-revoked) token matching a raw bearer token value.
*
* Accepts mixed because callers pass request input directly, which PHP
* lets be an array (e.g. a client sending token[]=a) - casting that to
* string would throw, so anything non-string is just treated as absent.
*/
public static function findActive(mixed $rawToken): ?self
{
if (! is_string($rawToken) || $rawToken === '') {
return null;
}
return self::where('token_hash', hash('sha256', $rawToken))
->whereNull('revoked_at')
->first();
}
protected function isRevoked(): Attribute
{
return Attribute::make(
get: fn () => $this->revoked_at !== null,
);
}
}

View file

@ -7,7 +7,6 @@ namespace App\Models;
use App\CommonMark\Generators\MentionGenerator; use App\CommonMark\Generators\MentionGenerator;
use App\CommonMark\Renderers\MentionRenderer; use App\CommonMark\Renderers\MentionRenderer;
use App\Observers\NoteObserver; use App\Observers\NoteObserver;
use GuzzleHttp\Client;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\ObservedBy; 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\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\IndieWeb\Numbers; use Jonnybarnes\IndieWeb\Numbers;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
@ -363,18 +363,15 @@ class Note extends Model
$latLng = $latitude.','.$longitude; $latLng = $latitude.','.$longitude;
return Cache::get($latLng, function () use ($latLng, $latitude, $longitude) { return Cache::get($latLng, function () use ($latLng, $latitude, $longitude) {
$guzzle = resolve(Client::class); $response = Http::withHeaders(['User-Agent' => 'jonnybarnes.uk, email jonny@jonnybarnes.uk'])
$response = $guzzle->request('GET', 'https://nominatim.openstreetmap.org/reverse', [ ->get('https://nominatim.openstreetmap.org/reverse', [
'query' => [
'format' => 'json', 'format' => 'json',
'lat' => $latitude, 'lat' => $latitude,
'lon' => $longitude, 'lon' => $longitude,
'zoom' => 18, 'zoom' => 18,
'addressdetails' => 1, 'addressdetails' => 1,
], ]);
'headers' => ['User-Agent' => 'jonnybarnes.uk via Guzzle, email jonny@jonnybarnes.uk'], $json = $response->object();
]);
$json = json_decode((string) $response->getBody());
if (isset($json->address->suburb)) { if (isset($json->address->suburb)) {
$locality = $json->address->suburb; $locality = $json->address->suburb;
if (isset($json->address->city)) { if (isset($json->address->city)) {

18
app/Models/Setting.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
use HasFactory;
/**
* @var array<string, string>
*/
protected $casts = [
'winter_effect_enabled' => 'boolean',
];
}

View file

@ -2,19 +2,13 @@
namespace App\Providers; namespace App\Providers;
use GuzzleHttp\Client; use App\Models\Setting;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Intervention\Image\ImageManager;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
@ -33,11 +27,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void 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. * Paginate a standard Laravel Collection.
* *
@ -62,17 +51,6 @@ class AppServiceProvider extends ServiceProvider
); );
}); });
// Configure JWT builder
$this->app->bind('Lcobucci\JWT\Configuration', function () {
$key = InMemory::plainText(config('app.key'));
$config = Configuration::forSymmetricSigner(new Sha256, $key);
$config->setValidationConstraints(new SignedWith(new Sha256, $key));
return $config;
});
// Configure HtmlSanitizer // Configure HtmlSanitizer
$this->app->bind(HtmlSanitizer::class, function () { $this->app->bind(HtmlSanitizer::class, function () {
return new HtmlSanitizer( return new HtmlSanitizer(
@ -82,39 +60,15 @@ class AppServiceProvider extends ServiceProvider
); );
}); });
// Configure Guzzle
$this->app->bind('RetryGuzzle', function () {
$handlerStack = HandlerStack::create();
$handlerStack->push(Middleware::retry(
function ($retries, $request, $response, $exception) {
// Limit the number of retries to 5
if ($retries >= 5) {
return false;
}
// Retry connection exceptions
if ($exception instanceof ConnectException) {
return true;
}
// Retry on server errors
if ($response && $response->getStatusCode() >= 500) {
return true;
}
// Finally for CloudConvert, retry if status is not final
return json_decode($response, false, 512, JSON_THROW_ON_ERROR)->data->status !== 'finished';
},
function () {
// Retry after 1 second
return 1000;
}
));
return new Client(['handler' => $handlerStack]);
});
// Turn on Eloquent strict mode when developing // Turn on Eloquent strict mode when developing
Model::shouldBeStrict(! $this->app->isProduction()); Model::shouldBeStrict(! $this->app->isProduction());
// Force HTTPS URL generation in production
URL::forceHttps($this->app->isProduction());
// Share whether the winter snow effect is enabled with the base layout
View::composer('master', function ($view) {
$view->with('winterEffectEnabled', Setting::first()?->winter_effect_enabled ?? false);
});
} }
} }

View file

@ -8,12 +8,29 @@ use App\Models\Article;
class ArticleService class ArticleService
{ {
/**
* @throws \InvalidArgumentException if a published article already has this title
*/
public function create(array $data): Article public function create(array $data): Article
{ {
return Article::create([ $attributes = [
'title' => $data['name'], 'title' => $data['name'],
'main' => $data['content'], 'main' => $data['content'],
'published' => true, 'published' => ($data['post-status'] ?? null) !== 'draft',
]); ];
$existing = Article::where('title', $data['name'])->first();
if ($existing !== null) {
if ($existing->published) {
throw new \InvalidArgumentException("An article titled \"{$data['name']}\" has already been published");
}
$existing->update($attributes);
return $existing;
}
return Article::create($attributes);
} }
} }

View file

@ -8,10 +8,8 @@ use App\Exceptions\InternetArchiveException;
use App\Jobs\ProcessBookmark; use App\Jobs\ProcessBookmark;
use App\Models\Bookmark; use App\Models\Bookmark;
use App\Models\Tag; use App\Models\Tag;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class BookmarkService class BookmarkService
@ -55,21 +53,19 @@ class BookmarkService
* Given a URL, attempt to save it to the Internet Archive. * Given a URL, attempt to save it to the Internet Archive.
* *
* @throws InternetArchiveException * @throws InternetArchiveException
* @throws GuzzleException
*/ */
public function getArchiveLink(string $url): string public function getArchiveLink(string $url): string
{ {
$client = resolve(Client::class); $response = Http::get('https://web.archive.org/save/'.$url);
try {
$response = $client->request('GET', 'https://web.archive.org/save/'.$url); if ($response->clientError()) {
} catch (ClientException $e) {
// throw an exception to be caught // throw an exception to be caught
throw new InternetArchiveException; throw new InternetArchiveException;
} }
if ($response->hasHeader('Content-Location')) {
if (Str::startsWith(Arr::get($response->getHeader('Content-Location'), 0), '/web')) { $contentLocation = $response->header('Content-Location');
return $response->getHeader('Content-Location')[0]; if ($contentLocation !== '' && Str::startsWith($contentLocation, '/web')) {
} return $contentLocation;
} }
// throw an exception to be caught // throw an exception to be caught

View file

@ -24,6 +24,7 @@ class EntryData extends MicropubData
public readonly mixed $checkin, public readonly mixed $checkin,
public readonly mixed $syndication, public readonly mixed $syndication,
public readonly ?array $photos, public readonly ?array $photos,
public readonly ?string $postStatus,
) {} ) {}
public static function fromRequest(Request $request): static public static function fromRequest(Request $request): static
@ -49,6 +50,7 @@ class EntryData extends MicropubData
checkin: Arr::get($data, 'properties.checkin.0'), checkin: Arr::get($data, 'properties.checkin.0'),
syndication: Arr::get($data, 'properties.syndication.0'), syndication: Arr::get($data, 'properties.syndication.0'),
photos: Arr::get($data, 'properties.photo'), photos: Arr::get($data, 'properties.photo'),
postStatus: Arr::get($data, 'properties.post-status.0'),
); );
} }
@ -67,6 +69,7 @@ class EntryData extends MicropubData
checkin: $request->input('checkin'), checkin: $request->input('checkin'),
syndication: $request->input('syndication'), syndication: $request->input('syndication'),
photos: $request->input('photos'), photos: $request->input('photos'),
postStatus: $request->input('post-status'),
); );
} }
@ -100,6 +103,7 @@ class EntryData extends MicropubData
checkin: $data['checkin'] ?? null, checkin: $data['checkin'] ?? null,
syndication: $data['syndication'] ?? null, syndication: $data['syndication'] ?? null,
photos: $data['photos'] ?? null, photos: $data['photos'] ?? null,
postStatus: $data['post-status'] ?? null,
); );
} }
@ -120,6 +124,7 @@ class EntryData extends MicropubData
'checkin' => $this->checkin, 'checkin' => $this->checkin,
'syndication' => $this->syndication, 'syndication' => $this->syndication,
'photos' => $this->photos, 'photos' => $this->photos,
'post-status' => $this->postStatus,
]; ];
} }
} }

View file

@ -24,9 +24,7 @@ class CardHandler implements MicropubHandlerInterface
assert($data instanceof CardData); assert($data instanceof CardData);
$scopes = $data->tokenData['scope']; $scopes = $data->tokenData['scope'];
if (is_string($scopes)) { $scopes = explode(' ', $scopes);
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) { if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException; throw new InvalidTokenScopeException;

View file

@ -27,9 +27,7 @@ class EntryHandler implements MicropubHandlerInterface
assert($data instanceof EntryData); assert($data instanceof EntryData);
$scopes = $data->tokenData['scope']; $scopes = $data->tokenData['scope'];
if (is_string($scopes)) { $scopes = explode(' ', $scopes);
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) { if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException; throw new InvalidTokenScopeException;
@ -39,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
$location = match (true) { $location = match (true) {
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link, isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
default => resolve(NoteService::class)->create($dataArray)->uri, default => resolve(NoteService::class)->create($dataArray)->uri,
}; };

View file

@ -30,9 +30,7 @@ class UpdateHandler implements MicropubHandlerInterface
assert($data instanceof UpdateData); assert($data instanceof UpdateData);
$scopes = $data->tokenData['scope']; $scopes = $data->tokenData['scope'];
if (is_string($scopes)) { $scopes = explode(' ', $scopes);
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes, true)) { if (! in_array('update', $scopes, true)) {
throw new InvalidTokenScopeException; throw new InvalidTokenScopeException;

View file

@ -5,28 +5,26 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Jobs\AddClientToDatabase; use App\Jobs\AddClientToDatabase;
use DateTimeImmutable; use App\Models\MicropubToken;
use Lcobucci\JWT\Configuration;
class TokenService class TokenService
{ {
/** /**
* Generate a JWT token. * Generate a new bearer token.
*/ */
public function getNewToken(array $data): string public function getNewToken(array $data): string
{ {
$config = resolve(Configuration::class); $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$token = $config->builder() MicropubToken::create([
->issuedAt(new DateTimeImmutable) 'token_hash' => hash('sha256', $token),
->withClaim('client_id', $data['client_id']) 'client_id' => $data['client_id'],
->withClaim('me', $data['me']) 'me' => $data['me'],
->withClaim('scope', $data['scope']) 'scope' => $data['scope'],
->withClaim('nonce', bin2hex(random_bytes(8))) ]);
->getToken($config->signer(), $config->signingKey());
dispatch(new AddClientToDatabase($data['client_id'])); dispatch(new AddClientToDatabase($data['client_id']));
return $token->toString(); return $token;
} }
} }

View file

@ -17,8 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
->append(LinkHeadersMiddleware::class) ->append(LinkHeadersMiddleware::class)
->preventRequestForgery( ->preventRequestForgery(
except: [ except: [
'auth', // This is the IndieAuth auth endpoint 'auth', // This is the IndieAuth auth endpoint
'token', // This is the IndieAuth token 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/post',
'api/media', 'api/media',
'micropub/places', 'micropub/places',

View file

@ -14,20 +14,18 @@
"ext-pgsql": "*", "ext-pgsql": "*",
"ext-sodium": "*", "ext-sodium": "*",
"cviebrock/eloquent-sluggable": "^13.0", "cviebrock/eloquent-sluggable": "^13.0",
"guzzlehttp/guzzle": "^7.2",
"indieauth/client": "^1.1", "indieauth/client": "^1.1",
"intervention/image": "^3", "intervention/image": "^4.0",
"jonnybarnes/indieweb": "~0.2", "jonnybarnes/indieweb": "~0.2",
"jonnybarnes/webmentions-parser": "~0.5", "jonnybarnes/webmentions-parser": "~0.5",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/horizon": "^5.0", "laravel/horizon": "^5.0",
"laravel/scout": "^10.1", "laravel/scout": "^10.1",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0", "league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3", "mf2/mf2": "~0.3",
"spatie/laravel-flare": "^2.2", "spatie/laravel-flare": "^3.0",
"symfony/html-sanitizer": "^8.0", "symfony/html-sanitizer": "^8.0",
"tempest/highlight": "^2.27", "tempest/highlight": "^2.27",
"web-auth/webauthn-lib": "^5.0" "web-auth/webauthn-lib": "^5.0"
@ -42,8 +40,8 @@
"laravel/sail": "^1.41", "laravel/sail": "^1.41",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"phpunit/php-code-coverage": "^12.0", "phpunit/php-code-coverage": "^14.0",
"phpunit/phpunit": "^12.5.12", "phpunit/phpunit": "^13.0",
"spatie/laravel-ray": "^1.12", "spatie/laravel-ray": "^1.12",
"spatie/x-ray": "^1.2" "spatie/x-ray": "^1.2"
}, },

1349
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,6 @@
<?php <?php
use Spatie\FlareClient\Api;
use Spatie\FlareClient\Sampling\RateSampler; use Spatie\FlareClient\Sampling\RateSampler;
use Spatie\LaravelFlare\AttributesProviders\LaravelUserAttributesProvider;
use Spatie\LaravelFlare\FlareConfig; use Spatie\LaravelFlare\FlareConfig;
use Spatie\LaravelFlare\Senders\LaravelHttpSender; use Spatie\LaravelFlare\Senders\LaravelHttpSender;
@ -21,17 +19,6 @@ return [
'key' => env('FLARE_KEY'), 'key' => 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 | Collects
@ -47,22 +34,6 @@ return [
extra: [] 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 | Censor data
@ -88,19 +59,49 @@ return [
'X-XSRF-TOKEN', 'X-XSRF-TOKEN',
], ],
'client_ips' => false, '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, | The sender is responsible for sending the error reports and traces to
| no matter which error level you specified in the Flare log channel. | Flare. By default, Laravel Flare sends them over HTTP. To use the local
| Flare daemon, switch the sender class to
| `Spatie\FlareClient\Senders\DaemonSender::class` and set `daemon_url`.
| The daemon sender defaults to localhost on port 8787 and uses its own
| default timeouts and fallback sender config unless you override them.
| |
*/ */
'send_logs_as_events' => true, 'sender' => [
'class' => LaravelHttpSender::class,
'config' => [
'timeout' => 10,
],
],
// Daemon sender example
// 'sender' => [
// 'class' => \Spatie\FlareClient\Senders\DaemonSender::class,
// 'config' => [
// 'daemon_url' => env('FLARE_DAEMON_URL', 'http://127.0.0.1:8787'),
// ],
// ],
/*
|--------------------------------------------------------------------------
| Report
|--------------------------------------------------------------------------
|
| Flare reports errors and exceptions happening within your application.
|
*/
'report' => env('FLARE_REPORT', true),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -109,10 +110,27 @@ return [
| When reporting errors, you can specify which error levels should be | When reporting errors, you can specify which error levels should be
| reported. By default, all error levels are reported by setting | reported. By default, all error levels are reported by setting
| this value to `null`. | this value to `null`.
*/ */
'report_error_levels' => 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 | Share button
@ -126,40 +144,6 @@ return [
'enable_share_button' => true, '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 | Trace
@ -170,7 +154,7 @@ return [
| |
*/ */
'trace' => env('FLARE_TRACE', false), 'trace' => env('FLARE_TRACE', true),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -183,26 +167,35 @@ return [
| which means that 10% of the traces will be recorded. | which means that 10% of the traces will be recorded.
| |
*/ */
'sampler' => [ 'sampler' => [
'class' => RateSampler::class, 'class' => RateSampler::class,
'config' => [ '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 | Logging show you an overview of log entries within your application.
| the tracing data from growing too large.
| |
*/ */
'trace_limits' => [
'max_spans' => 512, 'log' => env('FLARE_LOG', false),
'max_attributes_per_span' => 128,
'max_span_events_per_span' => 128, /*
'max_attributes_per_span_event' => 128, |--------------------------------------------------------------------------
], | 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,
]; ];

View file

@ -1,22 +0,0 @@
<?php
use Intervention\Image\Drivers\Gd\Driver;
return [
/*
|--------------------------------------------------------------------------
| Image Driver
|--------------------------------------------------------------------------
|
| Intervention Image supports "GD Library" and "Imagick" to process images
| internally. You may choose one of them according to your PHP
| configuration. By default PHP's "GD Library" implementation is used.
|
| Supported: "gd", "imagick"
|
*/
'driver' => Driver::class,
];

View file

@ -39,4 +39,8 @@ return [
'token' => env('CLOUDCONVERT_API_TOKEN'), 'token' => env('CLOUDCONVERT_API_TOKEN'),
], ],
'brrr' => [
'webhook_url' => env('BRRR_WEBHOOK_URL'),
],
]; ];

View file

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\About;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<About>
*/
class AboutFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'content' => $this->faker->paragraph,
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Setting>
*/
class SettingFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'winter_effect_enabled' => false,
];
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('micropub_tokens', function (Blueprint $table) {
$table->id();
$table->string('token_hash', 64)->unique();
$table->string('client_id');
$table->string('me');
$table->string('scope');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index('client_id');
});
}
public function down(): void
{
Schema::dropIfExists('micropub_tokens');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->boolean('winter_effect_enabled')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('settings');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('about', function (Blueprint $table) {
$table->id();
$table->text('content');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('about');
}
};

314
package-lock.json generated
View file

@ -121,9 +121,9 @@
} }
}, },
"node_modules/@csstools/css-calc": { "node_modules/@csstools/css-calc": {
"version": "3.2.1", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -168,9 +168,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.6", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -237,9 +237,9 @@
} }
}, },
"node_modules/@csstools/selector-resolve-nested": { "node_modules/@csstools/selector-resolve-nested": {
"version": "4.0.0", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz",
"integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", "integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -725,9 +725,9 @@
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -782,9 +782,9 @@
} }
}, },
"node_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.6.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -1019,9 +1019,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.62.0", "version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1135,16 +1135,16 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.7", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@ -1418,9 +1418,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "10.6.0", "version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@ -1430,7 +1430,7 @@
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5", "@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.6.0", "@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1", "@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2", "@eslint/plugin-kit": "^0.7.2",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
@ -1454,7 +1454,7 @@
"imurmurhash": "^0.1.4", "imurmurhash": "^0.1.4",
"is-glob": "^4.0.0", "is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1", "json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.4", "minimatch": "^10.2.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3" "optionator": "^0.9.3"
}, },
@ -1655,9 +1655,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1749,9 +1749,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.2", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -1836,9 +1836,9 @@
} }
}, },
"node_modules/globby": { "node_modules/globby": {
"version": "16.2.0", "version": "16.2.2",
"resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz",
"integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", "integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1857,9 +1857,9 @@
} }
}, },
"node_modules/globby/node_modules/ignore": { "node_modules/globby/node_modules/ignore": {
"version": "7.0.5", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2137,9 +2137,9 @@
} }
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true, "dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
@ -2153,23 +2153,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-android-arm64": "1.32.0", "lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.32.0", "lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.32.0", "lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.32.0", "lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.32.0" "lightningcss-win32-x64-msvc": "1.33.0"
} }
}, },
"node_modules/lightningcss-android-arm64": { "node_modules/lightningcss-android-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2188,9 +2188,9 @@
} }
}, },
"node_modules/lightningcss-cli": { "node_modules/lightningcss-cli": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli/-/lightningcss-cli-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli/-/lightningcss-cli-1.33.0.tgz",
"integrity": "sha512-IFb/ChmSEbeWU3xeRybR6WFlJXCvfDS84//PUzLrRACgvoWrwRJBmcPS9azSo7LMh5QqEuyKanPBByhKM5z01Q==", "integrity": "sha512-/tBcBZlBFFxy1iYKDC/HSH/NEjv7Frq6dmDVKRbhGJQI/gFJIJ1/4ZXclxJkd0eEjBjDc5MY0YugHcvJVVGJVg==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MPL-2.0", "license": "MPL-2.0",
@ -2208,23 +2208,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-cli-android-arm64": "1.32.0", "lightningcss-cli-android-arm64": "1.33.0",
"lightningcss-cli-darwin-arm64": "1.32.0", "lightningcss-cli-darwin-arm64": "1.33.0",
"lightningcss-cli-darwin-x64": "1.32.0", "lightningcss-cli-darwin-x64": "1.33.0",
"lightningcss-cli-freebsd-x64": "1.32.0", "lightningcss-cli-freebsd-x64": "1.33.0",
"lightningcss-cli-linux-arm-gnueabihf": "1.32.0", "lightningcss-cli-linux-arm-gnueabihf": "1.33.0",
"lightningcss-cli-linux-arm64-gnu": "1.32.0", "lightningcss-cli-linux-arm64-gnu": "1.33.0",
"lightningcss-cli-linux-arm64-musl": "1.32.0", "lightningcss-cli-linux-arm64-musl": "1.33.0",
"lightningcss-cli-linux-x64-gnu": "1.32.0", "lightningcss-cli-linux-x64-gnu": "1.33.0",
"lightningcss-cli-linux-x64-musl": "1.32.0", "lightningcss-cli-linux-x64-musl": "1.33.0",
"lightningcss-cli-win32-arm64-msvc": "1.32.0", "lightningcss-cli-win32-arm64-msvc": "1.33.0",
"lightningcss-cli-win32-x64-msvc": "1.32.0" "lightningcss-cli-win32-x64-msvc": "1.33.0"
} }
}, },
"node_modules/lightningcss-cli-android-arm64": { "node_modules/lightningcss-cli-android-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-android-arm64/-/lightningcss-cli-android-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-android-arm64/-/lightningcss-cli-android-arm64-1.33.0.tgz",
"integrity": "sha512-4O3QY+VdgpBZLIq4crcKOEPVAXX0p7zDoykuTVstRtyolg9XU8CntdtbxcMPSC4SkzAuM/W4KsnPAzmv6Jb5LA==", "integrity": "sha512-c0Xd7Gxaw3mNOyrb9ET1JH3JjuB69GY/6pHO4vgwEbIoHCXAt6DBg6kxr5t/oZL9LRxdnZCXF+o0Evz6kuR/xA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2243,9 +2243,9 @@
} }
}, },
"node_modules/lightningcss-cli-darwin-arm64": { "node_modules/lightningcss-cli-darwin-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-arm64/-/lightningcss-cli-darwin-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-arm64/-/lightningcss-cli-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Xx+zeD7bDKJZwbd1N63TJfIUHEtYspf+tqObdnQEJEvZAwmGfA4iEGrkCRT8R57tDRBDSXg3XHMDDvo/cq7gBQ==", "integrity": "sha512-sso5hSFPis7ldw2FcBopsZviSVAXWRGX8ybUwMhQHssTlERenQJ88WihZs08tCdSnQULo2kunnrGhh4VjRw8Fg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2264,9 +2264,9 @@
} }
}, },
"node_modules/lightningcss-cli-darwin-x64": { "node_modules/lightningcss-cli-darwin-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-x64/-/lightningcss-cli-darwin-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-x64/-/lightningcss-cli-darwin-x64-1.33.0.tgz",
"integrity": "sha512-fYWANZ8RJDpI0tBcPQ7oBOYihfXmgDBHR4lZ6d4z7rcRLlZAOeI00mTO0IXAKfSm/UgnatjM4aBuNlLh8L9noA==", "integrity": "sha512-/aDYpMv2QKpJoSIwvwpIs4tIX9SCU894eospSm55FR5MxDZkJDy4fEX6elsXkHCBDqbAknb4qd2h/oDMLTC4Ew==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2285,9 +2285,9 @@
} }
}, },
"node_modules/lightningcss-cli-freebsd-x64": { "node_modules/lightningcss-cli-freebsd-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-freebsd-x64/-/lightningcss-cli-freebsd-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-freebsd-x64/-/lightningcss-cli-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-TJm7z1Ghvo9FKAza2KvfkFgH/9rcV1xAhCYQZlLrV0CiuTZ17uzLobBWb9oelGWUG+wTnEs6XEl4h/ve61YySg==", "integrity": "sha512-kK9u4IEAvt3+1m7lkloyfZNfXykXq2vCH7lgZ5aEqa8VuBZYqvcAIQ4Qt1593QJxbko9HEKUP0erdSxw7k7eFw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2306,9 +2306,9 @@
} }
}, },
"node_modules/lightningcss-cli-linux-arm-gnueabihf": { "node_modules/lightningcss-cli-linux-arm-gnueabihf": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm-gnueabihf/-/lightningcss-cli-linux-arm-gnueabihf-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm-gnueabihf/-/lightningcss-cli-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-/jS9L5p3eexs5QJvARiDGxibz1umKJmmtff86fWXl3r7RbEUFrMAD4q1WhAR7DurRFgc1YWld6r0mX1YHEYttA==", "integrity": "sha512-4bHmUlYCb8WKdPKx2yhk8y/kD034JVuUatDMWav0uU/hEIYJrxPimWBJJ4w2lgpcWchVnk/Kuihs4vaLjO/ozw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -2327,9 +2327,9 @@
} }
}, },
"node_modules/lightningcss-cli-linux-arm64-gnu": { "node_modules/lightningcss-cli-linux-arm64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-gnu/-/lightningcss-cli-linux-arm64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-gnu/-/lightningcss-cli-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-PSdjwcRtSrJpsaqnY3ebnCfDOJ5ePi8s/0ItL3CX1b1Eu0iy44xo7MjgES1ZOQ2ntOthPRqaGsbAiVBLbgFLYQ==", "integrity": "sha512-DkYmkBix3icAZKZsxGsjBAj59CIV0zflZtsT66lCPd2z3OqBLJlhKRrw4TlAW7mIkYeOEYWKVz/G00ZGXNse4A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2351,9 +2351,9 @@
} }
}, },
"node_modules/lightningcss-cli-linux-arm64-musl": { "node_modules/lightningcss-cli-linux-arm64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-musl/-/lightningcss-cli-linux-arm64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-musl/-/lightningcss-cli-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-t6DdpXFtEdonZHzRgH8cmqhC2o4tl0KrD33cDBBniJz5TmWS20MJxb4YEr4WqBLksqW8GE399HJo+g8/YVuKcA==", "integrity": "sha512-euNOhzo1ysRl719HfFzRe9r9usWO2Z/Nb8RnmqXxb/kvzOgAv2BcflpoTu6tWhSrbHX6YS/WP6mMFrmII6T7Hw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2375,9 +2375,9 @@
} }
}, },
"node_modules/lightningcss-cli-linux-x64-gnu": { "node_modules/lightningcss-cli-linux-x64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-gnu/-/lightningcss-cli-linux-x64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-gnu/-/lightningcss-cli-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-QMQllbHYkbkQ4N+v8OGExQlGHBc3YZIcKlVkYucQQj66thkFQsRjmv8p5q3iCB0inNsCoSZ8lBspugkU1iPlPg==", "integrity": "sha512-9Q4DAglm17bLhA+HZPB+vxjrHXdhR/8U44zyc8MQNA5Rw+tisvcoR/lv4iuBUFn2qreYVRWQq0wP0rgdhTSxyQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2399,9 +2399,9 @@
} }
}, },
"node_modules/lightningcss-cli-linux-x64-musl": { "node_modules/lightningcss-cli-linux-x64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-musl/-/lightningcss-cli-linux-x64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-musl/-/lightningcss-cli-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-dMSWdk4kMAi5f+J1xetxRCDQOvPix2whT0UdjuwP8r/5Xcdl2SQU/c80MQzu1S82kVDEANs1vHVEHp/+26LUnA==", "integrity": "sha512-+EobHdqxQ21SUMF8OU0qcizNIrLpPDaPTKQCaGbra9gL3NrW3ELG+exZl5y+WzJsEjLZ0hhjbCFjHUL/03H2JA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2423,9 +2423,9 @@
} }
}, },
"node_modules/lightningcss-cli-win32-arm64-msvc": { "node_modules/lightningcss-cli-win32-arm64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-win32-arm64-msvc/-/lightningcss-cli-win32-arm64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-win32-arm64-msvc/-/lightningcss-cli-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-MJo22OqSp9FLV10nTRDJQhP6zkEBFqBFQe9mnjfCs+G1Ft+QimIPmC+gBqZHFXveOBOsjFLzU72q0FPvRWFmZQ==", "integrity": "sha512-iwQ+8rHQy3ewC4c43Ik3UZ8TCC/rjShP3r/8TOPw4fT8wQmW7dS3BNNHPK+LWz9qNrObATJ4MZEEZVRneckOBw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2444,9 +2444,9 @@
} }
}, },
"node_modules/lightningcss-cli-win32-x64-msvc": { "node_modules/lightningcss-cli-win32-x64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-cli-win32-x64-msvc/-/lightningcss-cli-win32-x64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-cli-win32-x64-msvc/-/lightningcss-cli-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-nOBHLPeePXZpGR4Ptp+gkOIkr9pxlq2dFmO954ol5zBi/iOKxuD1hUTIQ7aG+ldKIx4eH9jwoTfZ6owUkm1paA==", "integrity": "sha512-sgtNPw2gxnY8OzCCMTKCkHSIiBYb/wfXLpLMmDIJFzcX2OETY+VOnQd7OBHx+hVSlrMU7iWL6oJ5rc+nQwteVA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2465,9 +2465,9 @@
} }
}, },
"node_modules/lightningcss-darwin-arm64": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2486,9 +2486,9 @@
} }
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2507,9 +2507,9 @@
} }
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2528,9 +2528,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -2549,9 +2549,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2573,9 +2573,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2597,9 +2597,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2621,9 +2621,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2645,9 +2645,9 @@
} }
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2666,9 +2666,9 @@
} }
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2808,9 +2808,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2953,9 +2953,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2966,9 +2966,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2986,7 +2986,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -3236,9 +3236,9 @@
} }
}, },
"node_modules/string-width": { "node_modules/string-width": {
"version": "8.2.1", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3269,9 +3269,9 @@
} }
}, },
"node_modules/stylelint": { "node_modules/stylelint": {
"version": "17.14.0", "version": "17.14.1",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz",
"integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==", "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3287,7 +3287,7 @@
"dependencies": { "dependencies": {
"@csstools/css-calc": "^3.2.1", "@csstools/css-calc": "^3.2.1",
"@csstools/css-parser-algorithms": "^4.0.0", "@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/css-tokenizer": "^4.0.0",
"@csstools/media-query-list-parser": "^5.0.0", "@csstools/media-query-list-parser": "^5.0.0",
"@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-resolve-nested": "^4.0.0",
@ -3299,9 +3299,9 @@
"debug": "^4.4.3", "debug": "^4.4.3",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"file-entry-cache": "^11.1.3", "file-entry-cache": "^11.1.5",
"global-modules": "^2.0.0", "global-modules": "^2.0.0",
"globby": "^16.2.0", "globby": "^16.2.1",
"globjoin": "^0.1.4", "globjoin": "^0.1.4",
"html-tags": "^5.1.0", "html-tags": "^5.1.0",
"ignore": "^7.0.5", "ignore": "^7.0.5",
@ -3311,12 +3311,12 @@
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"postcss": "^8.5.15", "postcss": "^8.5.16",
"postcss-safe-parser": "^7.0.1", "postcss-safe-parser": "^7.0.1",
"postcss-selector-parser": "^7.1.4", "postcss-selector-parser": "^7.1.4",
"postcss-value-parser": "^4.2.0", "postcss-value-parser": "^4.2.0",
"string-width": "^8.2.1", "string-width": "^8.2.1",
"supports-hyperlinks": "^4.4.0", "supports-hyperlinks": "^4.5.0",
"svg-tags": "^1.0.0", "svg-tags": "^1.0.0",
"table": "^6.9.0", "table": "^6.9.0",
"write-file-atomic": "^7.0.1" "write-file-atomic": "^7.0.1"
@ -3400,9 +3400,9 @@
} }
}, },
"node_modules/stylelint/node_modules/ignore": { "node_modules/stylelint/node_modules/ignore": {
"version": "7.0.5", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1,2 +1,2 @@
(()=>{var l=class{constructor(){}async register(){let r=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(r.challenge),rp:{id:r.rp.id,name:r.rp.name},user:{id:new TextEncoder().encode(window.atob(r.user.id)),name:r.user.name,displayName:r.user.displayName},pubKeyCredParams:r.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:r.authenticatorSelection,timeout:6e4},t=await navigator.credentials.create({publicKey:e});if(!t)throw new Error("Error generating a passkey");let n={id:t.id?t.id:null,type:t.type?t.type:null,rawId:t.rawId?this.bufferToBase64URLString(t.rawId):null,response:{attestationObject:t.response.attestationObject?this.bufferToBase64URLString(t.response.attestationObject):null,clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let r=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(r.challenge),userVerification:r.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let t={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(t),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(r){let e=r.replace(/-/g,"+").replace(/_/g,"/"),t=(4-e.length%4)%4,n=e.padEnd(e.length+t,"="),a=window.atob(n),o=new ArrayBuffer(a.length),i=new Uint8Array(o);for(let s=0;s<a.length;s++)i[s]=a.charCodeAt(s);return o}bufferToBase64URLString(r){let e=new Uint8Array(r),t="";for(let a of e)t+=String.fromCharCode(a);return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector")}setupEventListeners(){this.widget.querySelectorAll("#theme-selector-dropdown input").forEach(r=>r.addEventListener("input",e=>{let t=e.target.value;this.widget.querySelectorAll(".toggle svg").forEach(u=>{u.classList.contains(t)?u.style.display="":u.style.display="none"});let n;switch(t){case"dark":case"light":n=t;break;default:n="light dark"}let a=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",o=document.querySelector("html"),i=o.style.getPropertyValue("color-scheme");i===""&&(i="light dark");let s=!1;n!==i&&(n==="light dark"?s=i!==a:i==="light dark"?s=n!==a:s=!0),o.style.viewTransitionName="changing-theme",s&&document.startViewTransition?document.startViewTransition(()=>{document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n)}):(document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n))}))}};var h=new l;document.querySelectorAll(".add-passkey").forEach(c=>{c.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(c=>{c.addEventListener("click",()=>{h.login()})});var p=new d;p.setupEventListeners();})(); (()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c<n.length;c++)o[c]=n.charCodeAt(c);return a}bufferToBase64URLString(t){let e=new Uint8Array(t),r="";for(let n of e)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme="system"}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})();
//# sourceMappingURL=app.js.map //# sourceMappingURL=app.js.map

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,163 @@
@layer components {
.token-list-wrapper {
margin-block-start: 1em;
border: 1px solid var(--clr-border);
border-radius: 16px;
overflow: auto hidden;
background: light-dark(
oklch(99% 0.02 var(--primary-hue)),
oklch(22% 0.05 var(--primary-hue))
);
}
.token-list {
width: 100%;
min-width: 640px;
table-layout: fixed;
border-collapse: collapse;
th,
td {
text-align: left;
padding: 0.9em 1.2em;
border-bottom: 1px solid var(--clr-border);
vertical-align: middle;
}
th {
font-size: 0.8em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.7;
}
tr.is-revoked {
opacity: 0.6;
}
a {
word-break: break-all;
}
th:nth-child(1),
td:nth-child(1) {
width: 31%;
}
th:nth-child(2),
td:nth-child(2) {
width: 24%;
}
th:nth-child(3),
td:nth-child(3) {
width: 15%;
}
th:nth-child(4),
td:nth-child(4) {
width: 16%;
}
th:nth-child(5),
td:nth-child(5) {
width: 14%;
text-align: right;
}
tr:last-child td {
border-bottom: none;
}
}
.scope-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.scope-chip {
display: inline-block;
padding: 0.2em 0.7em;
border-radius: 999px;
background: light-dark(
oklch(92% 0.05 var(--primary-hue)),
oklch(35% 0.08 var(--primary-hue))
);
font-size: 0.85em;
white-space: nowrap;
}
.badge,
.token-list button.revoke {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.3em 0.8em;
border-radius: 999px;
font-size: 0.85em;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.badge {
background: transparent;
border: 1px solid currentcolor;
}
.badge-active {
color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentcolor;
}
}
.badge-revoked {
color: var(--clr-text);
opacity: 0.7;
}
.token-list button.revoke {
background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));
color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));
border: 1px solid transparent;
cursor: pointer;
transition: background-color 150ms ease;
}
.token-list button.revoke:hover {
background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));
}
.token-reveal {
margin-block-end: 1em;
padding: 1em 1.2em;
border: 1px solid var(--clr-border);
border-radius: 16px;
background: light-dark(
oklch(96% 0.08 145deg),
oklch(28% 0.08 145deg)
);
input {
width: 100%;
font-family: monospace;
padding: 0.5em 0.7em;
border-radius: 8px;
border: 1px solid var(--clr-border);
}
}
.scope-checkboxes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1em;
}
}

View file

@ -8,3 +8,4 @@
@import url('notes.css'); @import url('notes.css');
@import url('pagination.css'); @import url('pagination.css');
@import url('theme-selector.css'); @import url('theme-selector.css');
@import url('admin-tokens.css');

View file

@ -18,7 +18,17 @@
} }
> main { > 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 { > footer {

View file

@ -1,72 +1,68 @@
@layer components { @layer components {
#theme-selector { #theme-selector {
display: flex;
justify-self: start;
align-items: center;
gap: .25rem;
padding: .25rem;
border-radius: 999px;
background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);
button { button {
display: grid;
place-items: center;
appearance: none; appearance: none;
border: none; border: none;
background: none; border-radius: 999px;
padding: .375rem;
background: transparent;
color: inherit;
transition: background-color .2s, color .2s;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
} }
}
/* This is the element with the [popover] attribute */ &:focus-visible {
#theme-selector-dropdown { outline: 2px solid var(--clr-text);
position: absolute; outline-offset: 2px;
position-area: block-end span-inline-start;
margin: 0;
border: 2px solid var(--clr-border);
background-color: var(--clr-background);
color: var(--clr-text);
fieldset {
border: 0;
display: flex;
flex-direction: row;
gap: 1ch;
input {
display: none;
}
} }
}
/* svg {
* This is the element with the [popover] attribute width: 1.5rem;
* Here we are styling the open and closing animations height: 1.5rem;
*/ fill: currentcolor;
@media (prefers-reduced-motion: no-preference) { }
#theme-selector-dropdown {
&:popover-open {
transform: translateY(0) scale(1);
opacity: 1;
/* @media (hover: hover) {
* Start styles for the opening transition. &:hover {
* Added to :popover-open, but after the opened styles. background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);
*/
@starting-style {
transform: translateY(30px) scale(0);
opacity: 0;
}
} }
/* &[aria-pressed="true"]:hover {
* End styles for the closing transition. background-color: var(--clr-text);
*/ }
transform: translateY(0) scale(0); }
opacity: 0;
/* &[aria-pressed="true"] {
* Enumerate transitioning properties, including display and overlay. background-color: var(--clr-text);
*/ color: var(--clr-background);
transition: transform, opacity, display allow-discrete, overlay allow-discrete;
transition-duration: 0.5s;
transform-origin: top right;
} }
} }
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
::view-transition-group(changing-theme) { ::view-transition-group(changing-theme) {
animation-duration: 1.25s; animation-duration: 1.25s;
} }

View file

@ -1,69 +1,60 @@
class ThemeSelector { class ThemeSelector {
constructor() { constructor() {
this.widget = document.querySelector('#theme-selector'); this.widget = document.querySelector('#theme-selector');
this.btnLight = this.widget.querySelector('#btn-light');
this.btnDark = this.widget.querySelector('#btn-dark');
this.status = this.widget.querySelector('#theme-status');
this.currentTheme = 'system';
} }
setupEventListeners() { setupEventListeners() {
this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => { this.btnLight.addEventListener('click', () => {
let theme = e.target.value; this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');
});
// Update current icon this.btnDark.addEventListener('click', () => {
this.widget.querySelectorAll('.toggle svg').forEach((svg) => { this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
if (svg.classList.contains(theme)) { });
svg.style.display = ''; }
} else {
svg.style.display = 'none';
}
});
// Set the theme applyTheme(theme) {
let selectedTheme; const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';
switch (theme) {
case 'dark':
case 'light':
selectedTheme = theme;
break;
default:
selectedTheme = 'light dark';
}
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const html = document.querySelector('html'); const html = document.documentElement;
let currentTheme = html.style.getPropertyValue('color-scheme'); let currentColorScheme = html.style.getPropertyValue('color-scheme');
if (currentTheme === '') { if (currentColorScheme === '') {
currentTheme = 'light dark'; currentColorScheme = 'light dark';
} }
/* do we need to transition */ /* do we need to transition */
let doTransition = false; let doTransition = false;
if (selectedTheme !== currentTheme) { if (selectedTheme !== currentColorScheme) {
if (selectedTheme === 'light dark') { if (selectedTheme === 'light dark') {
doTransition = currentTheme !== systemTheme; doTransition = currentColorScheme !== systemTheme;
} else if (currentTheme === 'light dark') { } else if (currentColorScheme === 'light dark') {
doTransition = selectedTheme !== systemTheme; doTransition = selectedTheme !== systemTheme;
} else {
doTransition = true;
}
}
html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) {
document.startViewTransition(() => {
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
});
} else { } else {
// Close the popover doTransition = true;
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
} }
})); }
const applyChange = () => {
this.currentTheme = theme;
html.style.setProperty('color-scheme', selectedTheme);
this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));
this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));
this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';
};
html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) {
document.startViewTransition(applyChange);
} else {
applyChange();
}
} }
} }

View file

@ -0,0 +1,9 @@
@extends('master')
@section('title')About « @stop
@section('content')
<h2>About</h2>
<div class="e-content">
{!! $about !!}
</div>
@stop

View file

@ -0,0 +1,19 @@
@extends('master')
@section('title')Edit About « Admin CP « @stop
@section('content')
<h1>Edit About</h1>
<form action="/admin/about" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
{{ method_field('PUT') }}
<div>
<label for="content">Content:</label>
<br>
<textarea name="content" id="content" rows="10" cols="50">{{ old('content', $aboutEntry?->content) }}</textarea>
</div>
<div>
<button type="submit" name="save">Save</button>
</div>
</form>
@stop

View file

@ -0,0 +1,26 @@
@extends('master')
@section('title')Site Settings « Admin CP « @stop
@section('content')
<h1>Site settings</h1>
<form action="/admin/settings" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
{{ method_field('PUT') }}
<div>
<label for="winter_effect_enabled">
<input
type="checkbox"
name="winter_effect_enabled"
id="winter_effect_enabled"
value="1"
@checked(old('winter_effect_enabled', $settings?->winter_effect_enabled))
>
Show winter snow effect
</label>
</div>
<div>
<button type="submit" name="save">Save</button>
</div>
</form>
@stop

View file

@ -0,0 +1,52 @@
@extends('master')
@section('title')New Token « Admin CP « @stop
@section('content')
<h1>Generate a new token</h1>
<p>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.</p>
<form action="/admin/tokens" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
<div>
<label for="client_id">Client</label>
<input
type="text"
name="client_id"
id="client_id"
value="{{ old('client_id') }}"
placeholder="https://ia.net/writer"
required
>
</div>
<div class="scope-checkboxes">
<span>Scope</span>
<label for="scope_create">
<input
type="checkbox"
name="scope[]"
id="scope_create"
value="create"
@checked(in_array('create', old('scope', []), true))
>
create
</label>
<label for="scope_update">
<input
type="checkbox"
name="scope[]"
id="scope_update"
value="update"
@checked(in_array('update', old('scope', []), true))
>
update
</label>
</div>
<div>
<button type="submit" name="save">Generate token</button>
</div>
</form>
@stop

View file

@ -0,0 +1,64 @@
@extends('master')
@section('title')List Tokens « Admin CP « @stop
@section('content')
<h1>Micropub Tokens</h1>
<p><a href="/admin/tokens/create">Generate new token</a></p>
@if(session('new_token'))
<div class="token-reveal">
<p>Here's your new token. <strong>Copy it now</strong> — it won't be shown again.</p>
<input type="text" readonly value="{{ session('new_token') }}" onclick="this.select()">
</div>
@endif
@if($tokens->isEmpty())
<p>No tokens have been issued.</p>
@else
<div class="full-bleed token-list-wrapper">
<table class="token-list">
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Scope</th>
<th scope="col">Issued</th>
<th scope="col">Status</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
@foreach($tokens as $token)
<tr class="{{ $token->isRevoked ? 'is-revoked' : '' }}">
<td><a href="{{ $token->client_id }}" rel="noopener">{{ $token->client_id }}</a></td>
<td>
<span class="scope-chips">
@foreach(explode(' ', $token->scope) as $scope)
<span class="scope-chip">{{ $scope }}</span>
@endforeach
</span>
</td>
<td><time datetime="{{ $token->created_at->toIso8601String() }}" title="{{ $token->created_at }}">{{ $token->created_at->diffForHumans() }}</time></td>
<td>
@if($token->isRevoked)
<span class="badge badge-revoked">Revoked {{ $token->revoked_at->diffForHumans() }}</span>
@else
<span class="badge badge-active"><span class="dot"></span>Active</span>
@endif
</td>
<td>
@unless($token->isRevoked)
<form action="/admin/tokens/{{ $token->id }}/revoke" method="post" onsubmit="return confirm('Revoke this token? This cannot be undone.')">
{{ csrf_field() }}
{{ method_field('PUT') }}
<button type="submit" class="revoke" name="revoke">Revoke</button>
</form>
@endunless
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@stop

View file

@ -47,13 +47,28 @@
or <a href="/admin/syndication">edit</a> them. or <a href="/admin/syndication">edit</a> them.
</p> </p>
<h2>Tokens</h2>
<p>
View and <a href="/admin/tokens">revoke</a> issued Micropub tokens.
</p>
<h2>Bio</h2> <h2>Bio</h2>
<p> <p>
Edit your <a href="/admin/bio">bio</a>. Edit your <a href="/admin/bio">bio</a>.
</p> </p>
<h2>About</h2>
<p>
Edit your <a href="/admin/about">about page</a>.
</p>
<h2>Passkeys</h2> <h2>Passkeys</h2>
<p> <p>
Manager <a href="/admin/passkeys">your passkeys</a>. Manager <a href="/admin/passkeys">your passkeys</a>.
</p> </p>
<h2>Settings</h2>
<p>
Edit <a href="/admin/settings">site settings</a>.
</p>
@stop @stop

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom feed for {{ config('user.display_name') }}s blog</title>
<link rel="self" href="{{ config('app.url') }}/blog/feed.atom" />
<id>{{ config('app.url')}}/blog</id>
<updated>{{ $articles[0]->updated_at->toAtomString() }}</updated>
@foreach($articles as $article)
<entry>
<title>{{ $article->title }}</title>
<link href="{{ config('app.url') }}{{ $article->link }}" />
<id>{{ config('app.url') }}{{ $article->link }}</id>
<updated>{{ $article->updated_at->toAtomString() }}</updated>
<content>{{ $article->main }}</content>
<author>
<name>{{ config('user.display_name') }}</name>
</author>
</entry>
@endforeach
</feed>

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ config('user.display_name') }}</title>
<atom:link href="{{ config('app.url') }}/blog/feed.rss" rel="self" type="application/rss+xml" />
<description>An RSS feed of the blog posts found on {{ config('app.url') }}</description>
<link>{{ config('app.url') }}/blog</link>
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
<ttl>1800</ttl>
@foreach($articles as $article)
<item>
<title>{{ strip_tags($article->title) }}</title>
<description>
<![CDATA[
{{ $article->main }}
@if($article->url)<p><a href="{{ config('app.url') }}{{ $article->link }}">Permalink</a></p>@endif
]]>
</description>
<link>@if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif</link>
<guid>{{ config('app.url') }}{{ $article->link }}</guid>
<pubDate>{{ $article->pubdate }}</pubDate>
</item>
@endforeach
</channel>
</rss>

View file

@ -0,0 +1,11 @@
@php
if (isset($title)) {
$uniqueId = bin2hex(random_bytes(6));
}
@endphp
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 200 200" class="feather feather-json-feed"
@if($title)aria-labelledby="{{ $uniqueId }}"@endif
>
@if($title)<title id="{{ $uniqueId }}">{{ $title }}</title>@endif
<path fill="#497714" transform="translate(0,200) scale(0.1,-0.1)" d="M373 1547 c-88 -95 -127 -171 -127 -252 -1 -90 28 -140 164 -280 150 -155 160 -168 160 -219 0 -31 -9 -55 -41 -101 -34 -51 -39 -65 -34 -95 3 -20 18 -46 36 -62 44 -41 89 -38 163 12 32 22 67 40 78 40 46 0 99 -38 227 -164 169 -165 209 -190 305 -190 92 -1 158 32 258 125 l73 67 -59 61 -60 60 -39 -36 c-22 -19 -60 -44 -84 -55 -73 -32 -109 -12 -278 152 -77 76 -156 145 -175 155 -47 24 -110 22 -159 -6 -23 -12 -44 -19 -48 -15 -4 4 2 25 15 46 24 42 29 110 12 154 -5 15 -66 84 -135 156 -138 143 -165 180 -165 227 0 47 15 78 61 128 l42 45 -54 59 c-30 32 -57 59 -61 60 -3 0 -37 -32 -75 -72z M1474 1631 c-36 -22 -58 -75 -48 -115 10 -41 59 -76 106 -76 107 0 137 147 41 199 -31 16 -64 14 -99 -8z M1229 1385 c-55 -30 -73 -89 -44 -145 19 -37 43 -50 95 -50 110 0 139 144 40 195 -36 18 -57 18 -91 0z M949 1111 c-86 -87 29 -228 130 -159 40 27 52 62 41 115 -12 51 -41 73 -97 73 -36 0 -50 -6 -74 -29z"/>
</svg>

View file

@ -7,13 +7,9 @@
<title>@yield('title'){{ config('app.name') }}</title> <title>@yield('title'){{ config('app.name') }}</title>
<link rel="stylesheet" href="/assets/highlight/nord.css"> <link rel="stylesheet" href="/assets/highlight/nord.css">
<link rel="stylesheet" href="/assets/css/app.css"> <link rel="stylesheet" href="/assets/css/app.css">
<link rel="alternate" type="application/rss+xml" title="Blog RSS Feed" href="{{ route('feed.blog.rss') }}"> <link rel="alternate" type="application/feed+json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
<link rel="alternate" type="application/atom+xml" title="Blog Atom Feed" href="{{ route('feed.blog.atom') }}">
<link rel="alternate" type="application/json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
<link rel="alternate" type="application/jf2feed+json" title="Blog JF2 Feed" href="{{ route('feed.blog.jf2') }}"> <link rel="alternate" type="application/jf2feed+json" title="Blog JF2 Feed" href="{{ route('feed.blog.jf2') }}">
<link rel="alternate" type="application/rss+xml" title="Notes RSS Feed" href="{{ route('feed.notes.rss') }}"> <link rel="alternate" type="application/feed+json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
<link rel="alternate" type="application/atom+xml" title="Notes Atom Feed" href="{{ route('feed.notes.atom') }}">
<link rel="alternate" type="application/json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
<link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="{{ route('feed.notes.jf2') }}"> <link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="{{ route('feed.notes.jf2') }}">
<link rel="indieauth-metadata" href="{{ route('indieauth.metadata') }}"> <link rel="indieauth-metadata" href="{{ route('indieauth.metadata') }}">
<link rel="authorization_endpoint" href="{{ route('indieauth.start') }}"> <link rel="authorization_endpoint" href="{{ route('indieauth.start') }}">
@ -40,37 +36,17 @@
<a href="/likes">Likes</a> <a href="/likes">Likes</a>
<a href="/contacts">Contacts</a> <a href="/contacts">Contacts</a>
<a href="/projects">Projects</a> <a href="/projects">Projects</a>
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.rss', ['title' => 'RSS Feed'])</a> <a href="/about">About</a>
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a>
</nav> </nav>
<div id="theme-selector"> <div id="theme-selector" role="region" aria-label="Theme switcher">
<button class="toggle" popovertarget="theme-selector-dropdown"> <button id="btn-light" aria-label="Light mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="system" style=""><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="dark" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="light" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</button> </button>
<div id="theme-selector-dropdown" popover> <button id="btn-dark" aria-label="Dark mode" aria-pressed="false">
<fieldset> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
<legend>Select theme:</legend> </button>
<div> <span id="theme-status" class="sr-only" aria-live="polite"></span>
<input type="radio" id="theme-system" name="theme" value="system" checked>
<label for="theme-system">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-dark" name="theme" value="dark">
<label for="theme-dark">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-light" name="theme" value="light">
<label for="theme-light">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</label>
</div>
</fieldset>
</div>
</div> </div>
</header> </header>
@ -106,14 +82,16 @@
@section('scripts') @section('scripts')
<script type="module" src="/assets/js/app.js"></script> <script type="module" src="/assets/js/app.js"></script>
<script type="module" src="/assets/js/is-land.min.js"></script> @if($winterEffectEnabled ?? false)
<script type="module" src="/assets/js/winter.js"></script> <script type="module" src="/assets/js/is-land.min.js"></script>
<is-land on:media="(prefers-reduced-motion: no-preference)"> <script type="module" src="/assets/js/winter.js"></script>
<snow-fall <is-land on:media="(prefers-reduced-motion: no-preference)">
count="150" <snow-fall
style="--snow-fall-color: var(--clr-snow-fall)" count="150"
></snow-fall> style="--snow-fall-color: var(--clr-snow-fall)"
</is-land> ></snow-fall>
</is-land>
@endif
@show @show
</body> </body>
</html> </html>

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom feed for {{ config('user.display_name') }}s notes</title>
<link rel="self" href="{{ config('app.url') }}/notes/feed.atom" />
<id>{{ config('app.url')}}/notes</id>
<updated>{{ $notes[0]->updated_at->toAtomString() }}</updated>
@foreach($notes as $note)
<entry>
<title>{{ strip_tags($note->note) }}</title>
<link href="{{ $note->uri }}" />
<id>{{ $note->uri }}</id>
<updated>{{ $note->updated_at->toAtomString() }}</updated>
<content type="html">{{ $note->note }}</content>
<author>
<name>{{ config('user.display_name') }}</name>
</author>
</entry>
@endforeach
</feed>

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ config('user.display_name') }}</title>
<atom:link href="{{ config('app.url') }}/notes/feed.rss" rel="self" type="application/rss+xml" />
<description>An RSS feed of the notes found on {{ config('app.url') }}</description>
<link>{{ config('app.url') }}/notes</link>
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
<ttl>1800</ttl>
@foreach($notes as $note)
<item>
<title>{{ strip_tags($note->note) }}</title>
<description>
<![CDATA[
{!! $note->note !!}
]]>
</description>
<link>{{ $note->uri }}</link>
<guid>{{ $note->uri}}</guid>
<pubDate>{{ $note->pubdate }}</pubDate>
</item>
@endforeach
</channel>
</rss>

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Http\Controllers\AboutPageController;
use App\Http\Controllers\Admin\AboutController;
use App\Http\Controllers\Admin\ArticlesController as AdminArticlesController; use App\Http\Controllers\Admin\ArticlesController as AdminArticlesController;
use App\Http\Controllers\Admin\BioController; use App\Http\Controllers\Admin\BioController;
use App\Http\Controllers\Admin\ClientsController; use App\Http\Controllers\Admin\ClientsController;
@ -9,7 +11,9 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController;
use App\Http\Controllers\Admin\NotesController as AdminNotesController; use App\Http\Controllers\Admin\NotesController as AdminNotesController;
use App\Http\Controllers\Admin\PasskeysController; use App\Http\Controllers\Admin\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; 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\SyndicationTargetsController;
use App\Http\Controllers\Admin\TokensController;
use App\Http\Controllers\ArticlesController; use App\Http\Controllers\ArticlesController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookmarksController; use App\Http\Controllers\BookmarksController;
@ -49,6 +53,9 @@ Route::view('projects', 'projects');
// Static colophon page // Static colophon page
Route::view('colophon', 'colophon'); Route::view('colophon', 'colophon');
// About page
Route::get('about', [AboutPageController::class, 'show']);
// The login routes to get authd for admin // The login routes to get authd for admin
Route::get('login', [AuthController::class, 'showLogin'])->name('login'); Route::get('login', [AuthController::class, 'showLogin'])->name('login');
Route::post('login', [AuthController::class, 'login']); Route::post('login', [AuthController::class, 'login']);
@ -147,12 +154,32 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::delete('/{clientId}', [ClientsController::class, 'destroy']); Route::delete('/{clientId}', [ClientsController::class, 'destroy']);
}); });
// Micropub Tokens
Route::prefix('tokens')->group(function () {
Route::get('/', [TokensController::class, 'index']);
Route::get('/create', [TokensController::class, 'create']);
Route::post('/', [TokensController::class, 'store']);
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
});
// Bio // Bio
Route::prefix('bio')->group(function () { Route::prefix('bio')->group(function () {
Route::get('/', [BioController::class, 'show'])->name('admin.bio.show'); Route::get('/', [BioController::class, 'show'])->name('admin.bio.show');
Route::put('/', [BioController::class, 'update']); Route::put('/', [BioController::class, 'update']);
}); });
// Settings
Route::prefix('settings')->group(function () {
Route::get('/', [SettingsController::class, 'show'])->name('admin.settings.show');
Route::put('/', [SettingsController::class, 'update']);
});
// About
Route::prefix('about')->group(function () {
Route::get('/', [AboutController::class, 'show'])->name('admin.about.show');
Route::put('/', [AboutController::class, 'update']);
});
// Passkeys // Passkeys
Route::prefix('passkeys')->group(function () { Route::prefix('passkeys')->group(function () {
Route::get('/', [PasskeysController::class, 'index']); Route::get('/', [PasskeysController::class, 'index']);
@ -163,8 +190,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
// Blog pages using ArticlesController // Blog pages using ArticlesController
Route::prefix('blog')->group(function () { 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.json', [FeedsController::class, 'blogJson'])->name('feed.blog.json');
Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2'); Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2');
Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']); Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']);
@ -175,8 +200,6 @@ Route::prefix('blog')->group(function () {
// Notes pages using NotesController // Notes pages using NotesController
Route::prefix('notes')->group(function () { Route::prefix('notes')->group(function () {
Route::get('/', [NotesController::class, 'index']); 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.json', [FeedsController::class, 'notesJson'])->name('feed.notes.json');
Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2'); Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2');
Route::get('/new', [NotesController::class, 'create']); Route::get('/new', [NotesController::class, 'create']);
@ -205,6 +228,8 @@ Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMidd
Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class); Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class);
Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); Route::post('auth', [IndieAuthController::class, 'processCodeExchange']);
Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token'); 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 // Micropub Endpoints
Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class);

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\About;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AboutPageTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function about_page_shows_content(): void
{
About::factory()->create([
'content' => 'This is the about page content.',
]);
$this->get('/about')
->assertSee('This is the about page content.');
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\About;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AboutTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_about_page_loads(): void
{
$user = User::factory()->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 <em>my</em> about page. It uses <strong>HTML</strong>.',
]);
$response = $this->actingAs($user)
->get('/admin/about');
$response->assertSeeText('This is <em>my</em> about page. It uses <strong>HTML</strong>.');
}
#[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',
]);
}
}

View file

@ -6,12 +6,9 @@ namespace Tests\Feature\Admin;
use App\Models\Contact; use App\Models\Contact;
use App\Models\User; 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\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -141,14 +138,12 @@ class ContactsTest extends TestCase
<img class="u-photo" alt="" src="http://tantek.com/tantek.png"> <img class="u-photo" alt="" src="http://tantek.com/tantek.png">
</div> </div>
HTML; HTML;
$file = fopen(__DIR__.'/../../aaron.png', 'rb'); $file = file_get_contents(__DIR__.'/../../aaron.png');
$mock = new MockHandler([ Http::fake([
new Response(200, ['Content-Type' => 'text/html'], $html), '*' => Http::sequence()
new Response(200, ['Content-Type' => 'image/png'], $file), ->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(); $user = User::factory()->make();
$contact = Contact::factory()->create([ $contact = Contact::factory()->create([
'homepage' => 'https://tantek.com', 'homepage' => 'https://tantek.com',
@ -165,12 +160,9 @@ class ContactsTest extends TestCase
#[Test] #[Test]
public function getting_remote_avatar_fails_gracefully_with_remote_not_found(): void public function getting_remote_avatar_fails_gracefully_with_remote_not_found(): void
{ {
$mock = new MockHandler([ Http::fake([
new Response(404), '*' => Http::response('', 404),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$user = User::factory()->make(); $user = User::factory()->make();
$contact = Contact::factory()->create(); $contact = Contact::factory()->create();
@ -187,13 +179,11 @@ class ContactsTest extends TestCase
<img class="u-photo" src="http://tantek.com/tantek.png"> <img class="u-photo" src="http://tantek.com/tantek.png">
</div> </div>
HTML; HTML;
$mock = new MockHandler([ Http::fake([
new Response(200, ['Content-Type' => 'text/html'], $html), '*' => Http::sequence()
new Response(404), ->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(); $user = User::factory()->make();
$contact = Contact::factory()->create(); $contact = Contact::factory()->create();

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SettingsTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_settings_page_loads(): void
{
$user = User::factory()->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);
}
}

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\MicropubToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TokensTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function index_requires_authentication(): void
{
$response = $this->get('/admin/tokens');
$response->assertRedirect();
}
#[Test]
public function index_lists_issued_tokens(): void
{
$user = User::factory()->make();
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create update',
]);
$response = $this->actingAs($user)->get('/admin/tokens');
$response->assertOk();
$response->assertSeeText($token->client_id);
}
#[Test]
public function create_requires_authentication(): void
{
$response = $this->get('/admin/tokens/create');
$response->assertRedirect();
}
#[Test]
public function create_shows_form(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->get('/admin/tokens/create');
$response->assertOk();
$response->assertSee('name="client_id"', false);
}
#[Test]
public function store_requires_authentication(): void
{
$response = $this->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => ['create'],
]);
$response->assertRedirect();
$this->assertDatabaseCount('micropub_tokens', 0);
}
#[Test]
public function store_creates_a_new_token_and_redirects_with_it_flashed(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => ['create', 'update'],
]);
$response->assertRedirect('/admin/tokens');
$response->assertSessionHas('new_token');
$this->assertDatabaseHas('micropub_tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => 'create update',
'me' => config('app.url'),
]);
$token = $response->getSession()->get('new_token');
$this->assertNotNull(MicropubToken::findActive($token));
}
#[Test]
public function store_requires_at_least_one_scope(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => [],
]);
$response->assertSessionHasErrors('scope');
$this->assertDatabaseCount('micropub_tokens', 0);
}
#[Test]
public function revoke_requires_authentication(): void
{
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create',
]);
$response = $this->put("/admin/tokens/{$token->id}/revoke");
$response->assertRedirect();
$this->assertFalse($token->fresh()->isRevoked);
}
#[Test]
public function revoke_marks_the_token_as_revoked(): void
{
$user = User::factory()->make();
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create',
]);
$this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
$this->assertTrue($token->fresh()->isRevoked);
}
#[Test]
public function revoke_redirects_to_index(): void
{
$user = User::factory()->make();
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create',
]);
$response = $this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
$response->assertRedirect('/admin/tokens');
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class CsrfExemptionsTest extends TestCase
{
/**
* CSRF verification is short-circuited entirely while running the test
* suite (see PreventRequestForgery::runningUnitTests()), so a normal
* feature test hitting these routes would pass even if they were never
* added to bootstrap/app.php's except list. Assert against the actual
* configured exemptions instead.
*/
#[Test]
public function external_api_endpoints_are_exempt_from_csrf_verification(): void
{
$exemptions = $this->app->make(PreventRequestForgery::class)->getExcludedPaths();
foreach (['auth', 'token', 'revocation', 'introspect', 'api/post', 'api/media', 'micropub/places', 'webmention'] as $path) {
$this->assertContains($path, $exemptions);
}
}
}

View file

@ -15,42 +15,6 @@ class FeedsTest extends TestCase
{ {
use RefreshDatabase; 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] #[Test]
public function blog_jf2_feed_is_present(): void 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. * Test the blog JSON feed.
*/ */
@ -93,7 +45,7 @@ class FeedsTest extends TestCase
{ {
Article::factory()->count(3)->create(); Article::factory()->count(3)->create();
$response = $this->get('/blog/feed.json'); $response = $this->get('/blog/feed.json');
$response->assertHeader('Content-Type', 'application/json'); $response->assertHeader('Content-Type', 'application/feed+json');
$response->assertOk(); $response->assertOk();
} }
@ -105,7 +57,7 @@ class FeedsTest extends TestCase
{ {
Note::factory()->count(3)->create(); Note::factory()->count(3)->create();
$response = $this->get('/notes/feed.json'); $response = $this->get('/notes/feed.json');
$response->assertHeader('Content-Type', 'application/json'); $response->assertHeader('Content-Type', 'application/feed+json');
$response->assertOk(); $response->assertOk();
} }

View file

@ -19,7 +19,9 @@ class HeaderLinkTest extends TestCase
$this->assertSame('<'.config('app.url').'/.well-known/indieauth-server>; rel="indieauth-metadata"', $linkHeaders[0]); $this->assertSame('<'.config('app.url').'/.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').'/auth>; rel="authorization_endpoint"', $linkHeaders[1]);
$this->assertSame('<'.config('app.url').'/token>; rel="token_endpoint"', $linkHeaders[2]); $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').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]);
$this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[4]); $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]);
} }
} }

View file

@ -4,15 +4,14 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\MicropubToken;
use App\Models\User; use App\Models\User;
use GuzzleHttp\Client; use App\Services\TokenService;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -30,9 +29,10 @@ class IndieAuthTest extends TestCase
'issuer' => config('app.url'), 'issuer' => config('app.url'),
'authorization_endpoint' => route('indieauth.start'), 'authorization_endpoint' => route('indieauth.start'),
'token_endpoint' => route('indieauth.token'), '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'], 'code_challenge_methods_supported' => ['S256'],
// 'introspection_endpoint' => 'introspection_endpoint',
// 'introspection_endpoint_auth_methods_supported' => ['none'],
]); ]);
} }
@ -269,12 +269,9 @@ class IndieAuthTest extends TestCase
</html> </html>
HTML; HTML;
$mockHandler = new MockHandler([ Http::fake([
new Response(200, [], $appPageHtml), '*' => Http::response($appPageHtml, 200),
]); ]);
$handlerStack = HandlerStack::create($mockHandler);
$mockGuzzleClient = new Client(['handler' => $handlerStack]);
$this->app->instance(Client::class, $mockGuzzleClient);
$user = User::factory()->make(); $user = User::factory()->make();
$url = url()->query('/auth', [ $url = url()->query('/auth', [
@ -313,12 +310,9 @@ class IndieAuthTest extends TestCase
</html> </html>
HTML; HTML;
$mockHandler = new MockHandler([ Http::fake([
new Response(200, [], $appPageHtml), '*' => Http::response($appPageHtml, 200),
]); ]);
$handlerStack = HandlerStack::create($mockHandler);
$mockGuzzleClient = new Client(['handler' => $handlerStack]);
$this->app->instance(Client::class, $mockGuzzleClient);
$user = User::factory()->make(); $user = User::factory()->make();
$url = url()->query('/auth', [ $url = url()->query('/auth', [
@ -701,4 +695,132 @@ class IndieAuthTest extends TestCase
'me' => config('app.url'), 'me' => config('app.url'),
]); ]);
} }
#[Test]
public function it_should_revoke_a_known_token(): void
{
$token = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => 'https://app.example.com',
'scope' => 'create',
]);
$response = $this->post('/revocation', ['token' => $token]);
$response->assertStatus(200);
$this->assertTrue(
MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->isRevoked
);
}
#[Test]
public function it_should_return200_for_an_unknown_token(): void
{
$response = $this->post('/revocation', ['token' => bin2hex(random_bytes(32))]);
$response->assertStatus(200);
}
#[Test]
public function introspection_requires_a_bearer_token(): void
{
$response = $this->post('/introspect', ['token' => 'irrelevant']);
$response->assertStatus(401);
}
#[Test]
public function introspection_rejects_a_revoked_bearer_token(): void
{
$callerToken = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => 'https://app.example.com',
'scope' => 'create',
]);
MicropubToken::where('token_hash', hash('sha256', $callerToken))->firstOrFail()->revoke();
$response = $this->post(
'/introspect',
['token' => 'irrelevant'],
['HTTP_Authorization' => 'Bearer '.$callerToken]
);
$response->assertStatus(401);
}
#[Test]
public function introspection_returns_active_details_for_a_valid_token(): void
{
$callerToken = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => 'https://app.example.com',
'scope' => 'create',
]);
$subjectToken = resolve(TokenService::class)->getNewToken([
'me' => 'https://someone-else.example.com',
'client_id' => 'https://quill.p3k.io',
'scope' => 'create update',
]);
$response = $this->post(
'/introspect',
['token' => $subjectToken],
['HTTP_Authorization' => 'Bearer '.$callerToken]
);
$response->assertStatus(200);
$response->assertJson([
'active' => true,
'me' => 'https://someone-else.example.com',
'client_id' => 'https://quill.p3k.io',
'scope' => 'create update',
]);
$response->assertJsonStructure(['iat']);
}
#[Test]
public function introspection_returns_only_active_false_for_an_unknown_token(): void
{
$callerToken = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => 'https://app.example.com',
'scope' => 'create',
]);
$response = $this->post(
'/introspect',
['token' => bin2hex(random_bytes(32))],
['HTTP_Authorization' => 'Bearer '.$callerToken]
);
$response->assertStatus(200);
$response->assertExactJson(['active' => false]);
}
#[Test]
public function revocation_does_not_error_on_an_array_shaped_token_param(): void
{
$response = $this->post('/revocation', ['token' => ['a', 'b']]);
$response->assertStatus(200);
}
#[Test]
public function introspection_does_not_error_on_an_array_shaped_token_param(): void
{
$callerToken = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => 'https://app.example.com',
'scope' => 'create',
]);
$response = $this->post(
'/introspect',
['token' => ['a', 'b']],
['HTTP_Authorization' => 'Bearer '.$callerToken]
);
$response->assertStatus(200);
$response->assertExactJson(['active' => false]);
}
} }

View file

@ -6,11 +6,8 @@ namespace Tests\Feature;
use App\Jobs\ProcessLike; use App\Jobs\ProcessLike;
use App\Models\Like; 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\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Authorship;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@ -98,18 +95,12 @@ class LikesTest extends TestCase
</html> </html>
END; END;
$mock = new MockHandler([ Http::fake([
new Response(200, [], $content), '*' => Http::response($content, 200),
new Response(200, [], $content),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$authorship = new Authorship; $authorship = new Authorship;
$job->handle($client, $authorship); $job->handle($authorship);
$this->assertEquals('Fred Bloggs', Like::find($id)->author_name); $this->assertEquals('Fred Bloggs', Like::find($id)->author_name);
} }
@ -141,18 +132,12 @@ class LikesTest extends TestCase
</html> </html>
END; END;
$mock = new MockHandler([ Http::fake([
new Response(200, [], $content), '*' => Http::response($content, 200),
new Response(200, [], $content),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$authorship = new Authorship; $authorship = new Authorship;
$job->handle($client, $authorship); $job->handle($authorship);
$this->assertEquals('Fred Bloggs', Like::find($id)->author_name); $this->assertEquals('Fred Bloggs', Like::find($id)->author_name);
} }
@ -177,18 +162,12 @@ class LikesTest extends TestCase
</html> </html>
END; END;
$mock = new MockHandler([ Http::fake([
new Response(200, [], $content), '*' => Http::response($content, 200),
new Response(200, [], $content),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$authorship = new Authorship; $authorship = new Authorship;
$job->handle($client, $authorship); $job->handle($authorship);
$this->assertNull(Like::find($id)->author_name); $this->assertNull(Like::find($id)->author_name);
} }

View file

@ -4,14 +4,17 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Exceptions\MicropubHandlerException;
use App\Jobs\SendWebMentions; use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon; use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Article;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
use App\Models\Place; use App\Models\Place;
use App\Models\SyndicationTarget; use App\Models\SyndicationTarget;
use Faker\Factory; use Faker\Factory;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -457,6 +460,11 @@ class MicropubControllerTest extends TestCase
#[Test] #[Test]
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void 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( $response = $this->postJson(
'/api/post', '/api/post',
[ [
@ -869,6 +877,99 @@ class MicropubControllerTest extends TestCase
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [
'title' => $name, 'title' => $name,
'main' => $content, 'main' => $content,
'published' => true,
]);
$response->assertHeader('Location');
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
}
#[Test]
public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void
{
$faker = Factory::create();
$name = $faker->text(50);
$content = $faker->paragraphs(5, true);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => [$name],
'content' => [$content],
'post-status' => ['draft'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
$this->assertDatabaseHas('articles', [
'title' => $name,
'main' => $content,
'published' => false,
]); ]);
} }
#[Test]
public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void
{
$draft = Article::create([
'title' => 'WireGuard',
'main' => 'Early draft content',
'published' => false,
]);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => ['WireGuard'],
'content' => ['Finished content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
$this->assertDatabaseHas('articles', [
'id' => $draft->id,
'title' => 'WireGuard',
'main' => 'Finished content',
'published' => true,
]);
}
#[Test]
public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void
{
Article::create([
'title' => 'WireGuard',
'main' => 'Published content',
'published' => true,
]);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => ['WireGuard'],
'content' => ['Some other content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'invalid_request'])
->assertStatus(400);
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
}
} }

View file

@ -4,18 +4,16 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\MicropubToken;
use App\Services\TokenService; use App\Services\TokenService;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
class TokenServiceTest extends TestCase class TokenServiceTest extends TestCase
{ {
/** /**
* Given the token is dependent on a random nonce, the time of creation and * Given the token is dependent on a random value and stored only as a
* the APP_KEY, to test, we shall create a token, and then verify it. * hash, to test, we shall create a token, and then verify it.
*/ */
#[Test] #[Test]
public function tokenservice_creates_valid_tokens(): void public function tokenservice_creates_valid_tokens(): void
@ -41,24 +39,29 @@ class TokenServiceTest extends TestCase
} }
#[Test] #[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 = [ $data = [
'me' => 'https://example.org', 'me' => 'https://example.org',
'client_id' => 'https://quill.p3k.io', 'client_id' => 'https://quill.p3k.io',
'scope' => 'post', 'scope' => 'post',
]; ];
$token = $tokenService->getNewToken($data);
$config = resolve(Configuration::class); MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke();
$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();
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]); $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]);
@ -68,4 +71,18 @@ class TokenServiceTest extends TestCase
'error_description' => 'The provided token did not pass validation', '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));
}
} }

View file

@ -3,11 +3,19 @@
namespace Tests; namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Http;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
use CreatesApplication; use CreatesApplication;
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
}
public function removeDirIfEmpty(string $dir): void public function removeDirIfEmpty(string $dir): void
{ {
// scandir() will always return `.` and `..` so even an “empty” // scandir() will always return `.` and `..` so even an “empty”

View file

@ -2,53 +2,39 @@
namespace Tests; namespace Tests;
use DateTimeImmutable; use App\Services\TokenService;
use Lcobucci\JWT\Configuration;
trait TestToken trait TestToken
{ {
public function getToken(): string public function getToken(): string
{ {
$config = $this->app->make(Configuration::class); return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
return $config->builder() 'me' => 'http://jonnybarnes.localhost',
->issuedAt(new DateTimeImmutable) 'scope' => 'create update',
->withClaim('client_id', 'https://quill.p3k.io') ]);
->withClaim('me', 'http://jonnybarnes.localhost')
->withClaim('scope', ['create', 'update'])
->getToken($config->signer(), $config->signingKey())
->toString();
} }
public function getTokenWithIncorrectScope(): string public function getTokenWithIncorrectScope(): string
{ {
$config = $this->app->make(Configuration::class); return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
return $config->builder() 'me' => 'https://jonnybarnes.localhost',
->issuedAt(new DateTimeImmutable) 'scope' => 'view',
->withClaim('client_id', 'https://quill.p3k.io') ]);
->withClaim('me', 'https://jonnybarnes.localhost')
->withClaim('scope', 'view')
->getToken($config->signer(), $config->signingKey())
->toString();
} }
public function getTokenWithNoScope() public function getTokenWithNoScope(): string
{ {
$config = $this->app->make(Configuration::class); return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
return $config->builder() 'me' => 'https://jonnybarnes.localhost',
->issuedAt(new DateTimeImmutable) 'scope' => '',
->withClaim('client_id', 'https://quill.p3k.io') ]);
->withClaim('me', 'https://jonnybarnes.localhost')
->getToken($config->signer(), $config->signingKey())
->toString();
} }
public function getInvalidToken() public function getInvalidToken(): string
{ {
$token = $this->getToken(); return bin2hex(random_bytes(32));
return substr($token, 0, -5);
} }
} }

View file

@ -63,6 +63,28 @@ class ArticlesTest extends TestCase
); );
} }
#[Test]
public function uri_is_the_absolute_form_of_the_link(): void
{
$article = Article::create([
'title' => 'Test',
'main' => 'Test',
]);
$this->assertEquals(config('app.url').$article->link, $article->uri);
}
#[Test]
public function slug_is_suffixed_when_a_trashed_article_already_used_it(): void
{
$original = Article::create(['title' => 'My Title', 'main' => 'Content']);
$original->delete();
$newArticle = Article::create(['title' => 'My Title', 'main' => 'Other content']);
$this->assertEquals('my-title-2', $newArticle->titleurl);
}
#[Test] #[Test]
public function date_scope_returns_expected_articles(): void public function date_scope_returns_expected_articles(): void
{ {

View file

@ -6,10 +6,7 @@ namespace Tests\Unit;
use App\Exceptions\InternetArchiveException; use App\Exceptions\InternetArchiveException;
use App\Services\BookmarkService; use App\Services\BookmarkService;
use GuzzleHttp\Client; use Illuminate\Support\Facades\Http;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -29,12 +26,9 @@ class BookmarksTest extends TestCase
#[Test] #[Test]
public function archive_link_method_calls_archive_service(): void public function archive_link_method_calls_archive_service(): void
{ {
$mock = new MockHandler([ Http::fake([
new Response(200, ['Content-Location' => '/web/1234/example.org']), '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'); $url = (new BookmarkService)->getArchiveLink('https://example.org');
$this->assertEquals('/web/1234/example.org', $url); $this->assertEquals('/web/1234/example.org', $url);
} }
@ -44,12 +38,9 @@ class BookmarksTest extends TestCase
{ {
$this->expectException(InternetArchiveException::class); $this->expectException(InternetArchiveException::class);
$mock = new MockHandler([ Http::fake([
new Response(403), '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'); (new BookmarkService)->getArchiveLink('https://example.org');
} }
@ -58,12 +49,9 @@ class BookmarksTest extends TestCase
{ {
$this->expectException(InternetArchiveException::class); $this->expectException(InternetArchiveException::class);
$mock = new MockHandler([ Http::fake([
new Response(200), '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'); (new BookmarkService)->getArchiveLink('https://example.org');
} }
} }

View file

@ -5,11 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Jobs\DownloadWebMention; use App\Jobs\DownloadWebMention;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\FileSystem\FileSystem; use Illuminate\FileSystem\FileSystem;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -35,19 +32,16 @@ class DownloadWebMentionJobTest extends TestCase
</div> </div>
HTML; HTML;
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html); $html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
$mock = new MockHandler([ Http::fake([
new Response(200, ['X-Foo' => 'Bar'], $html), 'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']),
new Response(200, ['X-Foo' => 'Bar'], $html),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$job = new DownloadWebMention($source); $job = new DownloadWebMention($source);
$job->handle($client); $job->handle();
$this->assertFileExists(storage_path('HTML/https')); $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'); $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;
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html); $html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
$html2 = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html2); $html2 = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html2);
$mock = new MockHandler([ Http::fake([
new Response(200, ['X-Foo' => 'Bar'], $html), 'example.org/*' => Http::sequence()
new Response(200, ['X-Foo' => 'Bar'], $html2), ->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 = new DownloadWebMention($source);
$job->handle($client); $job->handle();
$this->assertFileExists(storage_path('HTML/https')); $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'); $this->assertFileExists(storage_path('HTML/https/example.org/reply').'/1.'.date('Y-m-d').'.backup');
} }
@ -98,14 +91,12 @@ class DownloadWebMentionJobTest extends TestCase
</div> </div>
HTML; HTML;
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html); $html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
$mock = new MockHandler([ Http::fake([
new Response(200, ['X-Foo' => 'Bar'], $html), 'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$job = new DownloadWebMention($source); $job = new DownloadWebMention($source);
$job->handle($client); $job->handle();
$this->assertFileExists(storage_path('HTML/https/example.org/reply-one/index.html')); $this->assertFileExists(storage_path('HTML/https/example.org/reply-one/index.html'));
} }

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs;
use App\Jobs\NotifyBrrrOfWebMention;
use App\Models\WebMention;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class NotifyBrrrOfWebMentionJobTest extends TestCase
{
#[Test]
public function it_posts_a_reply_notification_to_the_brrr_webhook(): void
{
config(['services.brrr.webhook_url' => '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();
}
}

View file

@ -6,7 +6,6 @@ namespace Tests\Unit\Jobs;
use App\Jobs\ProcessMedia; use App\Jobs\ProcessMedia;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -15,10 +14,9 @@ class ProcessMediaJobTest extends TestCase
#[Test] #[Test]
public function non_media_files_are_not_saved(): void 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'); Storage::disk('local')->put('media/file.txt', 'This is not an image');
$job = new ProcessMedia('file.txt'); $job = new ProcessMedia('media/file.txt');
$job->handle($manager); $job->handle();
$this->assertFileDoesNotExist(storage_path('app/media/').'file.txt'); $this->assertFileDoesNotExist(storage_path('app/media/').'file.txt');
} }
@ -26,10 +24,9 @@ class ProcessMediaJobTest extends TestCase
#[Test] #[Test]
public function small_images_are_not_resized(): void 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')); Storage::disk('local')->put('media/aaron.png', file_get_contents(__DIR__.'/../../aaron.png'));
$job = new ProcessMedia('aaron.png'); $job = new ProcessMedia('media/aaron.png');
$job->handle($manager); $job->handle();
$this->assertFileDoesNotExist(storage_path('app/media/').'aaron.png'); $this->assertFileDoesNotExist(storage_path('app/media/').'aaron.png');
@ -41,10 +38,9 @@ class ProcessMediaJobTest extends TestCase
#[Test] #[Test]
public function large_images_have_smaller_images_created(): void 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')); Storage::disk('local')->put('media/test-image.jpg', file_get_contents(__DIR__.'/../../test-image.jpg'));
$job = new ProcessMedia('media/test-image.jpg'); $job = new ProcessMedia('media/test-image.jpg');
$job->handle($manager); $job->handle();
// These need to look in public disk // These need to look in public disk
Storage::disk('public')->assertExists('media/test-image.jpg'); Storage::disk('public')->assertExists('media/test-image.jpg');

View file

@ -5,16 +5,14 @@ declare(strict_types=1);
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Exceptions\RemoteContentNotFoundException; use App\Exceptions\RemoteContentNotFoundException;
use App\Jobs\NotifyBrrrOfWebMention;
use App\Jobs\ProcessWebMention; use App\Jobs\ProcessWebMention;
use App\Jobs\SaveProfileImage; use App\Jobs\SaveProfileImage;
use App\Models\Note; use App\Models\Note;
use App\Models\WebMention; use App\Models\WebMention;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\FileSystem\FileSystem; use Illuminate\FileSystem\FileSystem;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Jonnybarnes\WebmentionsParser\Parser; use Jonnybarnes\WebmentionsParser\Parser;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@ -39,17 +37,15 @@ class ProcessWebMentionJobTest extends TestCase
$this->expectException(RemoteContentNotFoundException::class); $this->expectException(RemoteContentNotFoundException::class);
$parser = new Parser; $parser = new Parser;
$mock = new MockHandler([ Http::fake([
new Response(404), '*' => Http::response('', 404),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(); $note = Note::factory()->create();
$source = 'https://example.org/mention/1/'; $source = 'https://example.org/mention/1/';
$job = new ProcessWebMention($note, $source); $job = new ProcessWebMention($note, $source);
$job->handle($parser, $client); $job->handle($parser);
} }
#[Test] #[Test]
@ -65,19 +61,18 @@ class ProcessWebMentionJobTest extends TestCase
</div> </div>
HTML; HTML;
$html = str_replace('href="', 'href="'.config('app.url'), $html); $html = str_replace('href="', 'href="'.config('app.url'), $html);
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(); $note = Note::factory()->create();
$source = 'https://example.org/mention/1/'; $source = 'https://example.org/mention/1/';
$job = new ProcessWebMention($note, $source); $job = new ProcessWebMention($note, $source);
$job->handle($parser, $client); $job->handle($parser);
Queue::assertPushed(SaveProfileImage::class); Queue::assertPushed(SaveProfileImage::class);
Queue::assertPushed(NotifyBrrrOfWebMention::class);
$this->assertDatabaseHas('webmentions', [ $this->assertDatabaseHas('webmentions', [
'source' => $source, 'source' => $source,
'type' => 'like-of', 'type' => 'like-of',
@ -103,16 +98,15 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">Updated reply</div> <div class="e-content">Updated reply</div>
</div> </div>
HTML; HTML;
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$job = new ProcessWebMention($note, $source); $job = new ProcessWebMention($note, $source);
$job->handle($parser, $client); $job->handle($parser);
Queue::assertPushed(SaveProfileImage::class); Queue::assertPushed(SaveProfileImage::class);
Queue::assertNotPushed(NotifyBrrrOfWebMention::class);
$this->assertDatabaseHas('webmentions', [ $this->assertDatabaseHas('webmentions', [
'source' => $source, 'source' => $source,
'type' => 'in-reply-to', 'type' => 'in-reply-to',
@ -132,11 +126,9 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">Replying to someone else</div> <div class="e-content">Replying to someone else</div>
</div> </div>
HTML; HTML;
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(); $note = Note::factory()->create();
$source = 'https://example.org/reply/1'; $source = 'https://example.org/reply/1';
@ -151,7 +143,7 @@ class ProcessWebMentionJobTest extends TestCase
]); ]);
$job = new ProcessWebMention($note, $source); $job = new ProcessWebMention($note, $source);
$job->handle($parser, $client); $job->handle($parser);
$this->assertDatabaseMissing('webmentions', [ $this->assertDatabaseMissing('webmentions', [
'source' => $source, 'source' => $source,
@ -169,11 +161,9 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">I like someone else now</div> <div class="e-content">I like someone else now</div>
</div> </div>
HTML; HTML;
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(); $note = Note::factory()->create();
$source = 'https://example.org/reply/1'; $source = 'https://example.org/reply/1';
@ -188,7 +178,7 @@ class ProcessWebMentionJobTest extends TestCase
]); ]);
$job = new ProcessWebMention($note, $source); $job = new ProcessWebMention($note, $source);
$job->handle($parser, $client); $job->handle($parser);
$this->assertDatabaseMissing('webmentions', [ $this->assertDatabaseMissing('webmentions', [
'source' => $source, 'source' => $source,
@ -208,19 +198,18 @@ class ProcessWebMentionJobTest extends TestCase
</div> </div>
HTML; HTML;
$html = str_replace('href="', 'href="'.config('app.url'), $html); $html = str_replace('href="', 'href="'.config('app.url'), $html);
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(); $note = Note::factory()->create();
// Simulate a long brid.gy Bluesky source URL (well over 255 characters) // 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'; $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 = new ProcessWebMention($note, $source);
$job->handle($parser, $client); $job->handle($parser);
Queue::assertPushed(NotifyBrrrOfWebMention::class);
$this->assertGreaterThan(255, strlen($source)); $this->assertGreaterThan(255, strlen($source));
$this->assertDatabaseHas('webmentions', [ $this->assertDatabaseHas('webmentions', [
'source' => $source, 'source' => $source,
@ -238,11 +227,9 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">Reposting someone else</div> <div class="e-content">Reposting someone else</div>
</div> </div>
HTML; HTML;
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(); $note = Note::factory()->create();
$source = 'https://example.org/reply/1'; $source = 'https://example.org/reply/1';
@ -257,7 +244,7 @@ class ProcessWebMentionJobTest extends TestCase
]); ]);
$job = new ProcessWebMention($note, $source); $job = new ProcessWebMention($note, $source);
$job->handle($parser, $client); $job->handle($parser);
$this->assertDatabaseMissing('webmentions', [ $this->assertDatabaseMissing('webmentions', [
'source' => $source, 'source' => $source,

View file

@ -5,10 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Jobs\SaveProfileImage; use App\Jobs\SaveProfileImage;
use GuzzleHttp\Client; use Illuminate\Support\Facades\Http;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@ -58,12 +55,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test] #[Test]
public function remote_author_images_are_saved_locally(): void public function remote_author_images_are_saved_locally(): void
{ {
$mock = new MockHandler([ Http::fake([
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'), '*' => 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' => []]; $mf = ['items' => []];
$author = [ $author = [
'properties' => [ 'properties' => [
@ -83,12 +77,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test] #[Test]
public function local_default_author_image_is_used_as_fallback(): void public function local_default_author_image_is_used_as_fallback(): void
{ {
$mock = new MockHandler([ Http::fake([
new Response(404), '*' => Http::response('', 404),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$mf = ['items' => []]; $mf = ['items' => []];
$author = [ $author = [
'properties' => [ 'properties' => [
@ -111,12 +102,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test] #[Test]
public function we_get_url_from_photo_object_if_alt_text_is_provided(): void public function we_get_url_from_photo_object_if_alt_text_is_provided(): void
{ {
$mock = new MockHandler([ Http::fake([
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'), '*' => 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' => []]; $mf = ['items' => []];
$author = [ $author = [
'properties' => [ 'properties' => [
@ -139,12 +127,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test] #[Test]
public function use_first_url_if_multiple_homepages_are_provided(): void public function use_first_url_if_multiple_homepages_are_provided(): void
{ {
$mock = new MockHandler([ Http::fake([
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'), '*' => 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' => []]; $mf = ['items' => []];
$author = [ $author = [
'properties' => [ 'properties' => [

View file

@ -6,13 +6,8 @@ namespace Tests\Unit\Jobs;
use App\Jobs\SaveScreenshot; use App\Jobs\SaveScreenshot;
use App\Models\Bookmark; 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\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -25,57 +20,34 @@ class SaveScreenshotJobTest extends TestCase
public function screenshot_is_saved_by_job(): void public function screenshot_is_saved_by_job(): void
{ {
Storage::fake('public'); 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"}}}'), Http::fake([
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"}}}'), 'api.cloudconvert.com/v2/capture-website' => Http::response([
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')), '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(); $bookmark = Bookmark::factory()->create();
$job = new SaveScreenshot($bookmark); $job = new SaveScreenshot($bookmark);
@ -84,68 +56,45 @@ class SaveScreenshotJobTest extends TestCase
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot); $this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png'); 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] #[Test]
public function screenshot_job_handles_unfinished_tasks(): void public function screenshot_job_handles_unfinished_tasks(): void
{ {
Storage::fake('public'); 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"}}}'), Http::fake([
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"}}}'), 'api.cloudconvert.com/v2/capture-website' => Http::response([
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')), '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(); $bookmark = Bookmark::factory()->create();
$job = new SaveScreenshot($bookmark); $job = new SaveScreenshot($bookmark);
@ -154,9 +103,10 @@ class SaveScreenshotJobTest extends TestCase
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot); $this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png'); Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png');
// Also assert we made the correct number of requests
$this->assertCount(2, $container); // capture-website, 2x poll (waiting then finished), export/url, 2x poll (waiting then finished), download
// However with retries there should be more than 4 responses for the 2 requests Http::assertSentCount(7);
$this->assertEquals(0, $retryMock->count()); // Also assert every queued response in each sequence was consumed, no more no less
Http::assertSequencesAreEmpty();
} }
} }

View file

@ -6,10 +6,7 @@ namespace Tests\Unit\Jobs;
use App\Jobs\SendWebMentions; use App\Jobs\SendWebMentions;
use App\Models\Note; use App\Models\Note;
use GuzzleHttp\Client; use Illuminate\Support\Facades\Http;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -28,12 +25,9 @@ class SendWebMentionJobTest extends TestCase
public function discover_webmention_endpoint_from_header_links(): void public function discover_webmention_endpoint_from_header_links(): void
{ {
$url = 'https://example.org/webmention'; $url = 'https://example.org/webmention';
$mock = new MockHandler([ Http::fake([
new Response(200, ['Link' => '<'.$url.'>; rel="webmention"']), '*' => 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); $job = new SendWebMentions(new Note);
$this->assertEquals($url, $job->discoverWebmentionEndpoint('https://example.org')); $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 public function discover_webmention_endpoint_from_html_link_tags(): void
{ {
$html = '<link rel="webmention" href="https://example.org/webmention">'; $html = '<link rel="webmention" href="https://example.org/webmention">';
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$job = new SendWebMentions(new Note); $job = new SendWebMentions(new Note);
$this->assertEquals( $this->assertEquals(
@ -61,12 +52,9 @@ class SendWebMentionJobTest extends TestCase
public function discover_webmention_endpoint_from_legacy_html_markup(): void public function discover_webmention_endpoint_from_legacy_html_markup(): void
{ {
$html = '<link rel="http://webmention.org/" href="https://example.org/webmention">'; $html = '<link rel="http://webmention.org/" href="https://example.org/webmention">';
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), '*' => Http::response($html, 200),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$job = new SendWebMentions(new Note); $job = new SendWebMentions(new Note);
$this->assertEquals( $this->assertEquals(
@ -95,13 +83,10 @@ class SendWebMentionJobTest extends TestCase
public function we_send_a_webmention_for_a_note(): void public function we_send_a_webmention_for_a_note(): void
{ {
$html = '<link rel="http://webmention.org/" href="https://example.org/webmention">'; $html = '<link rel="http://webmention.org/" href="https://example.org/webmention">';
$mock = new MockHandler([ Http::fake([
new Response(200, [], $html), 'example.org/webmention' => Http::response('', 202),
new 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 = new Note;
$note->note = 'Hi [Aaron](https://aaronparecki.com)'; $note->note = 'Hi [Aaron](https://aaronparecki.com)';
@ -114,13 +99,10 @@ class SendWebMentionJobTest extends TestCase
#[Test] #[Test]
public function links_in_notes_can_not_support_webmentions(): void public function links_in_notes_can_not_support_webmentions(): void
{ {
$mock = new MockHandler([ Http::fake([
// URLs with commas currently break the parse function Im using // URLs with commas currently break the parse function Im using
new Response(200, ['Link' => '<https://example.org/foo,bar>; rel="preconnect"']), '*' => Http::response('', 200, ['Link' => '<https://example.org/foo,bar>; rel="preconnect"']),
]); ]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$job = new SendWebMentions(new Note); $job = new SendWebMentions(new Note);
$this->assertNull($job->discoverWebmentionEndpoint('https://example.org')); $this->assertNull($job->discoverWebmentionEndpoint('https://example.org'));

Some files were not shown because too many files have changed in this diff Show more