Compare commits

...
Author SHA1 Message Date
cc2ff2b2a2
Drop the unused notes.shorturl column
The short domain is gone and nothing reads or writes this column any more.

Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:29:05 +00:00
73b9668088
Link to likes through the uri attribute in the like template
Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:29:00 +00:00
e8f2535cd1
Return our permalink in the Micropub Location header for likes
Likes returned `url`, which is the liked post rather than our page. Use
`uri` instead. Bookmarks already asked for `uri`, which didn't exist until
the previous commit, so their Location header was empty.

Neither case had a test, so assert the header value for both.

Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:29:00 +00:00
5481956d78
Use uri for the like and bookmark permalinks
`uri` is the attribute every other postable model uses for its absolute
permalink on this site. Add it to Like, which had none, and rename
Bookmark's `local_uri` to match. `url` stays as the thing being liked or
bookmarked.

Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:28:30 +00:00
7ce256baa5
Return 404 for non-numeric like and bookmark IDs
Constrain the likes/{like} and bookmarks/{bookmark} routes to numeric
IDs. Previously a junk URL such as /likes/twcamp^share|twsrc^ios was
passed to route model binding, and Postgres rejected the text-vs-integer
comparison with a QueryException, returning a 500 instead of a 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:01:28 +00:00
c3cf597950
Persist the theme selector choice across page loads
Save an explicit light/dark choice to localStorage and clear it when
switching back to system. An inline head script applies the saved
colour scheme before first paint to avoid a flash of the system theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:23:02 +01:00
d2bc91794b
Use self-hosted Libron and Cal Sans fonts
Libron is the body serif and Cal Sans is used for headings and the header
nav, both under the SIL OFL with licences alongside the font files. Both
are preloaded so the header doesn't shift while Cal Sans loads. Also
nudge note text up and note/article metadata down in size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:02:52 +01:00
a31c218784
Hide draft articles from the front page and mark drafts when viewed directly
Add an Article::published() scope and use it on the front page (which
previously showed drafts), the /blog listing and the feeds. Draft
articles are still reachable at their URL, but now show a draft notice,
a "[Draft]" title prefix and a noindex robots meta tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:20:50 +01:00
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
123 changed files with 3627 additions and 2005 deletions

View file

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

View file

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

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

View file

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

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

@ -17,7 +17,7 @@ class ArticlesController extends Controller
*/
public function index(?int $year = null, ?int $month = null): View
{
$articles = Article::where('published', '1')
$articles = Article::published()
->date($year, $month)
->orderBy('updated_at', 'desc')
->simplePaginate(5);

View file

@ -7,68 +7,15 @@ namespace App\Http\Controllers;
use App\Models\Article;
use App\Models\Note;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class FeedsController extends Controller
{
/**
* Returns the blog RSS feed.
*/
public function blogRss(): Response
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$buildDate = $articles->first()->updated_at->toRssString();
return response()
->view('articles.rss', compact('articles', 'buildDate'))
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
/**
* Returns the blog Atom feed.
*/
public function blogAtom(): Response
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
return response()
->view('articles.atom', compact('articles'))
->header('Content-Type', 'application/atom+xml; charset=utf-8');
}
/**
* Returns the notes RSS feed.
*/
public function notesRss(): Response
{
$notes = Note::latest()->take(20)->get();
$buildDate = $notes->first()->updated_at->toRssString();
return response()
->view('notes.rss', compact('notes', 'buildDate'))
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
/**
* Returns the notes Atom feed.
*/
public function notesAtom(): Response
{
$notes = Note::latest()->take(20)->get();
return response()
->view('notes.atom', compact('notes'))
->header('Content-Type', 'application/atom+xml; charset=utf-8');
}
/** @todo sort out return type for json responses */
/**
* Returns the blog JSON feed.
*/
public function blogJson(): array
public function blogJson(): JsonResponse
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$articles = Article::published()->latest('updated_at')->take(20)->get();
$data = [
'version' => 'https://jsonfeed.org/version/1.1',
'title' => 'The JSON Feed for '.config('user.display_name').'’s blog',
@ -94,13 +41,15 @@ class FeedsController extends Controller
];
}
return $data;
return response()->json($data, 200, [
'Content-Type' => 'application/feed+json',
]);
}
/**
* Returns the notes JSON feed.
*/
public function notesJson(): array
public function notesJson(): JsonResponse
{
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
$data = [
@ -130,7 +79,9 @@ class FeedsController extends Controller
}
}
return $data;
return response()->json($data, 200, [
'Content-Type' => 'application/feed+json',
]);
}
/**
@ -138,7 +89,7 @@ class FeedsController extends Controller
*/
public function blogJf2(): JsonResponse
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$articles = Article::published()->latest('updated_at')->take(20)->get();
$items = [];
foreach ($articles as $article) {
$items[] = [

View file

@ -26,7 +26,7 @@ class FrontPageController extends Controller
->withCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}])->get();
$articles = Article::latest()->get();
$articles = Article::published()->latest()->get();
$bookmarks = Bookmark::latest()->with('tags')->get();
$likes = Like::latest()->get();

View file

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

View file

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

View file

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

View file

@ -17,6 +17,8 @@ class LinkHeadersMiddleware
$response->header('Link', '<'.route('indieauth.metadata').'>; rel="indieauth-metadata"', false);
$response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', false);
$response->header('Link', '<'.route('indieauth.token').'>; rel="token_endpoint"', false);
$response->header('Link', '<'.route('indieauth.revocation').'>; rel="revocation_endpoint"', false);
$response->header('Link', '<'.route('indieauth.introspection').'>; rel="introspection_endpoint"', false);
$response->header('Link', '<'.route('micropub-endpoint').'>; rel="micropub"', false);
$response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false);

View file

@ -5,13 +5,9 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
use App\Models\MicropubToken;
use Closure;
use Illuminate\Http\Request;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken
@ -39,15 +35,15 @@ class VerifyMicropubToken
], 401);
}
try {
$tokenData = $this->validateToken($rawToken);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
$token = MicropubToken::findActive($rawToken);
if (! $token) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
if ($token->scope === '') {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
@ -56,26 +52,10 @@ class VerifyMicropubToken
return $next($request->merge([
'access_token' => $rawToken,
'token_data' => [
'me' => $tokenData->claims()->get('me'),
'scope' => $tokenData->claims()->get('scope'),
'client_id' => $tokenData->claims()->get('client_id'),
'me' => $token->me,
'scope' => $token->scope,
'client_id' => $token->client_id,
],
]));
}
/**
* Check the token signature is valid.
*/
private function validateToken(string $bearerToken): Token
{
$config = resolve(Configuration::class);
$token = $config->parser()->parse($bearerToken);
$constraints = $config->validationConstraints();
$config->validator()->assert($token, ...$constraints);
return $token;
}
}

View file

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

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

View file

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

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