Move webmention parsing and authorship into the app

The jonnybarnes/webmentions-parser package is now archived, and its
guzzlehttp/guzzle ^6|^7 constraint was the only thing holding Guzzle
back from 8. Port the parts we use into App\Services\WebMention and
drop the package.

Parser keeps getMentionType and the checkInReplyTo/LikeOf/RepostOf
checks unchanged. Authorship follows the same authorship algorithm,
with some fixes:

- fetch the author page with Laravel's Http client, so tests can fake
  it, and treat connection failures as AuthorshipParserException too
- keep state in locals so one instance can be reused safely
- make the url == uid == author-page check actually compare the
  h-card's uid, rather than an undefined variable

Add direct tests for both, and mark the library as archived on the
projects page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jonny Barnes 2026-11-07 16:23:49 +00:00
commit df9b0dc490
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
16 changed files with 526 additions and 64 deletions

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
class AuthorshipParserException extends Exception {}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
class InvalidMentionException extends Exception {}

View file

@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Exceptions\AuthorshipParserException;
use App\Models\Like;
use App\Services\WebMention\Authorship;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -12,8 +14,6 @@ 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;
class ProcessLike implements ShouldQueue
{

View file

@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Exceptions\InvalidMentionException;
use App\Exceptions\RemoteContentNotFoundException;
use App\Models\Note;
use App\Models\WebMention;
use App\Services\WebMention\Parser;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException;
use Jonnybarnes\WebmentionsParser\Parser;
use Mf2;
class ProcessWebMention implements ShouldQueue

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Exceptions\AuthorshipParserException;
use App\Services\WebMention\Authorship;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
@ -12,8 +14,6 @@ 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;
class SaveProfileImage implements ShouldQueue
{

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Services\WebMention\Authorship;
use App\Traits\FilterHtml;
use Exception;
use Illuminate\Database\Eloquent\Attributes\Guarded;
@ -13,7 +14,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Filesystem\Filesystem;
use Jonnybarnes\WebmentionsParser\Authorship;
#[Table('webmentions')]
#[Guarded(['id'])]

View file

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Services\WebMention;
use App\Exceptions\AuthorshipParserException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use function Mf2\parse;
/**
* Determine the author of a post from its parsed microformats, following
* the IndieWeb authorship algorithm: https://indieweb.org/authorship
*
* Originally from the jonnybarnes/webmentions-parser package.
*/
class Authorship
{
/**
* Find the author of the first h-entry (or h-feed) in the microformats.
*
* Returns the author's h-card, or their name as a string if that's all
* we have, or false if the author can't be determined.
*
* @throws AuthorshipParserException
*/
public function findAuthor(array $mf, bool $permalink = true): array|string|false
{
$hEntry = null;
$hFeed = null;
foreach ($mf['items'] ?? [] as $item) {
foreach ($item['type'] ?? [] as $type) {
if ($type === 'h-entry') {
$hEntry = $item;
} elseif ($type === 'h-feed') {
$hFeed = $item;
}
}
}
if ($hEntry === null && $hFeed === null) {
throw new AuthorshipParserException('No h-entry or h-feed found');
}
// If the h-entry has an author property use that
$author = $hEntry['properties']['author'] ?? null;
// A parent h-feed with an h-card takes precedence
foreach ($hFeed['children'] ?? [] as $child) {
if (($child['type'][0] ?? null) === 'h-card') {
$author = $child;
}
}
$authorPage = null;
if ($author !== null) {
// Flatten a single string entry
if (is_array($author) && count($author) === 1 && is_string($author[0] ?? null)) {
$author = $author[0];
}
// A URL author is the author-page, anything else is the author itself
if (is_string($author) && filter_var($author, FILTER_VALIDATE_URL)) {
$authorPage = $author;
} else {
return $this->normalise($author);
}
}
// If no author-page and h-entry is a permalink then use the rel-author link
if ($authorPage === null && $permalink && isset($mf['rels']['author'])) {
$authorPage = is_array($mf['rels']['author']) ? $mf['rels']['author'][0] : $mf['rels']['author'];
}
if ($authorPage === null) {
return false;
}
return $this->findAuthorOnPage($authorPage);
}
/**
* Fetch the author-page and look for a representative h-card on it.
*
* @throws AuthorshipParserException
*/
private function findAuthorOnPage(string $authorPage): array|false
{
try {
$response = Http::get($authorPage);
} catch (ConnectionException) {
throw new AuthorshipParserException('Unable to connect to the author’s page');
}
if ($response->failed()) {
throw new AuthorshipParserException('Unable to get the content from the author’s page');
}
$authorMf2 = parse($response->body(), $authorPage);
$hCards = array_filter(
$authorMf2['items'] ?? [],
fn (array $item): bool => in_array('h-card', $item['type'] ?? [], true)
);
// Use the first h-card where url == uid == author-page
foreach ($hCards as $hCard) {
if (
in_array($authorPage, $hCard['properties']['url'] ?? [], true)
&& in_array($authorPage, $hCard['properties']['uid'] ?? [], true)
) {
return $this->normalise($hCard);
}
}
// Else the first h-card with a url that matches a rel-me link on the page
$relMeLinks = $authorMf2['rels']['me'] ?? [];
foreach ($hCards as $hCard) {
foreach ($hCard['properties']['url'] ?? [] as $url) {
if (in_array($url, $relMeLinks, true)) {
return $this->normalise($hCard);
}
}
}
// Else the first h-card with url == author-page
foreach ($hCards as $hCard) {
if (in_array($authorPage, $hCard['properties']['url'] ?? [], true)) {
return $this->normalise($hCard);
}
}
return false;
}
private function normalise(array|string $author): array|string
{
if (is_array($author) && array_key_exists(0, $author)) {
$author = $author[0];
}
if (is_string($author)) {
return $author;
}
unset($author['value'], $author['children']);
return $author;
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Services\WebMention;
use App\Exceptions\InvalidMentionException;
/**
* Inspect the parsed microformats of a webmention source.
*
* Originally from the jonnybarnes/webmentions-parser package.
*/
class Parser
{
/**
* Return the type of mention.
*
* @throws InvalidMentionException when the mention type can't be determined
*/
public function getMentionType(array $microformats): string
{
foreach (['in-reply-to', 'like-of', 'repost-of'] as $type) {
if ($this->arrayKeyExistsRecursive($type, $microformats)) {
return $type;
}
}
throw new InvalidMentionException;
}
/**
* Check a reply is to the intended target.
*/
public function checkInReplyTo(array $microformats, string $target): bool
{
foreach ($microformats['items'] ?? [] as $item) {
$inReplyTo = $item['properties']['in-reply-to'] ?? null;
if ($inReplyTo === null) {
continue;
}
// The reply context may be an h-cite rather than a plain URL
if (is_array($inReplyTo[0]) && ($inReplyTo[0]['properties']['url'][0] ?? null) === $target) {
return true;
}
if (in_array($target, $inReplyTo, true)) {
return true;
}
}
return false;
}
/**
* Check the microformats contain a like of the target.
*/
public function checkLikeOf(array $microformats, string $target): bool
{
return in_array($target, $microformats['items'][0]['properties']['like-of'] ?? [], true);
}
/**
* Check the microformats contain a repost of the target.
*/
public function checkRepostOf(array $microformats, string $target): bool
{
return in_array($target, $microformats['items'][0]['properties']['repost-of'] ?? [], true);
}
private function arrayKeyExistsRecursive(string $needle, array $haystack): bool
{
if (array_key_exists($needle, $haystack)) {
return true;
}
foreach ($haystack as $value) {
if (is_array($value) && $this->arrayKeyExistsRecursive($needle, $value)) {
return true;
}
}
return false;
}
}

View file

@ -17,7 +17,6 @@
"indieauth/client": "^1.1",
"intervention/image": "^4.0",
"jonnybarnes/indieweb": "~0.2",
"jonnybarnes/webmentions-parser": "~0.5",
"laravel/framework": "^13.0",
"laravel/horizon": "^5.0",
"laravel/scout": "^11.0",

53
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "26051a7c122209f8ee213ca660fd0eb6",
"content-hash": "c05d8b5c324f0f2308155a5a65c35a0d",
"packages": [
{
"name": "aws/aws-crt-php",
@ -1752,57 +1752,6 @@
},
"time": "2020-11-29T11:13:10+00:00"
},
{
"name": "jonnybarnes/webmentions-parser",
"version": "v0.6",
"source": {
"type": "git",
"url": "https://github.com/jonnybarnes/webmentions-parser.git",
"reference": "cac1acb2b252d6eb611fc5f3a2c6e2265bc86b44"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jonnybarnes/webmentions-parser/zipball/cac1acb2b252d6eb611fc5f3a2c6e2265bc86b44",
"reference": "cac1acb2b252d6eb611fc5f3a2c6e2265bc86b44",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^6.0|^7.0",
"mf2/mf2": "~0.4",
"php": "^7.3|^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Jonnybarnes\\WebmentionsParser\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"CC0-1.0"
],
"authors": [
{
"name": "Jonny Barnes",
"homepage": "https://jonnybarnes.uk"
}
],
"description": "A PHP library to parse webmentions from HTML",
"keywords": [
"html",
"indieweb",
"microformats",
"webmentions"
],
"support": {
"issues": "https://github.com/jonnybarnes/webmentions-parser/issues",
"source": "https://github.com/jonnybarnes/webmentions-parser/tree/v0.6"
},
"time": "2020-11-29T11:40:36+00:00"
},
{
"name": "laravel/framework",
"version": "v13.40.0",

View file

@ -22,6 +22,8 @@
<p>
A tool to parse incoming webmentions to a site, including
determining the author of the source webmention.
This library is now archived and no longer maintained; its
code now lives directly in this site.
</p>
</div>
@stop

View file

@ -6,10 +6,10 @@ namespace Tests\Feature;
use App\Jobs\ProcessLike;
use App\Models\Like;
use App\Services\WebMention\Authorship;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Jonnybarnes\WebmentionsParser\Authorship;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
use Tests\TestToken;

View file

@ -10,11 +10,11 @@ use App\Jobs\ProcessWebMention;
use App\Jobs\SaveProfileImage;
use App\Models\Note;
use App\Models\WebMention;
use App\Services\WebMention\Parser;
use Illuminate\FileSystem\FileSystem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Jonnybarnes\WebmentionsParser\Parser;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Tests\Unit\Jobs;
use App\Exceptions\AuthorshipParserException;
use App\Jobs\SaveProfileImage;
use App\Services\WebMention\Authorship;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

View file

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\WebMention;
use App\Exceptions\AuthorshipParserException;
use App\Services\WebMention\Authorship;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AuthorshipTest extends TestCase
{
private const string AUTHOR_PAGE = 'https://fred.example/';
private function entryBy(mixed $author, array $rels = []): array
{
return [
'items' => [[
'type' => ['h-entry'],
'properties' => ['content' => ['A post'], 'author' => [$author]],
]],
'rels' => $rels,
];
}
private function hCard(string $name, array $extraProperties = []): array
{
return [
'type' => ['h-card'],
'properties' => array_merge(['name' => [$name], 'url' => [self::AUTHOR_PAGE]], $extraProperties),
'value' => $name,
];
}
#[Test]
public function it_returns_an_embedded_author_h_card(): void
{
$author = (new Authorship)->findAuthor($this->entryBy($this->hCard('Fred Bloggs')));
$this->assertSame('Fred Bloggs', $author['properties']['name'][0]);
$this->assertArrayNotHasKey('value', $author);
}
#[Test]
public function it_returns_a_plain_author_name(): void
{
$this->assertSame('Fred Bloggs', (new Authorship)->findAuthor($this->entryBy('Fred Bloggs')));
}
#[Test]
public function it_uses_an_h_card_in_the_parent_h_feed(): void
{
$mf = ['items' => [[
'type' => ['h-feed'],
'properties' => [],
'children' => [
['type' => ['h-entry'], 'properties' => ['content' => ['A post']]],
$this->hCard('Feed Author'),
],
]], 'rels' => []];
$author = (new Authorship)->findAuthor($mf);
$this->assertSame('Feed Author', $author['properties']['name'][0]);
}
#[Test]
public function it_fetches_a_url_author_and_uses_the_h_card_whose_url_and_uid_match(): void
{
Http::fake([self::AUTHOR_PAGE => Http::response(<<<'HTML'
<div class="h-card"><a class="p-name u-url" href="https://fred.example/">Not the uid one</a></div>
<div class="h-card"><a class="p-name u-url u-uid" href="https://fred.example/">Fred Bloggs</a></div>
HTML)]);
$author = (new Authorship)->findAuthor($this->entryBy(self::AUTHOR_PAGE));
$this->assertSame('Fred Bloggs', $author['properties']['name'][0]);
}
#[Test]
public function it_fetches_a_url_author_and_uses_the_h_card_matching_a_rel_me_link(): void
{
Http::fake([self::AUTHOR_PAGE => Http::response(<<<'HTML'
<link rel="me" href="https://social.example/@fred">
<div class="h-card"><span class="p-name">Someone Else</span><a class="u-url" href="https://elsewhere.example/"></a></div>
<div class="h-card"><span class="p-name">Fred Bloggs</span><a class="u-url" href="https://social.example/@fred"></a></div>
HTML)]);
$author = (new Authorship)->findAuthor($this->entryBy(self::AUTHOR_PAGE));
$this->assertSame('Fred Bloggs', $author['properties']['name'][0]);
}
#[Test]
public function it_fetches_a_url_author_and_uses_the_h_card_whose_url_is_the_author_page(): void
{
Http::fake([self::AUTHOR_PAGE => Http::response(<<<'HTML'
<div class="h-card"><a class="p-name u-url" href="https://elsewhere.example/">Someone Else</a></div>
<div class="h-card"><a class="p-name u-url" href="https://fred.example/">Fred Bloggs</a></div>
HTML)]);
$author = (new Authorship)->findAuthor($this->entryBy(self::AUTHOR_PAGE));
$this->assertSame('Fred Bloggs', $author['properties']['name'][0]);
}
#[Test]
public function it_falls_back_to_the_rel_author_link(): void
{
Http::fake([self::AUTHOR_PAGE => Http::response(
'<div class="h-card"><a class="p-name u-url" href="https://fred.example/">Fred Bloggs</a></div>'
)]);
$mf = ['items' => [['type' => ['h-entry'], 'properties' => ['content' => ['A post']]]], 'rels' => ['author' => [self::AUTHOR_PAGE]]];
$author = (new Authorship)->findAuthor($mf);
$this->assertSame('Fred Bloggs', $author['properties']['name'][0]);
}
#[Test]
public function it_ignores_the_rel_author_link_when_not_a_permalink(): void
{
Http::fake();
$mf = ['items' => [['type' => ['h-entry'], 'properties' => ['content' => ['A post']]]], 'rels' => ['author' => [self::AUTHOR_PAGE]]];
$this->assertFalse((new Authorship)->findAuthor($mf, permalink: false));
Http::assertNothingSent();
}
#[Test]
public function it_returns_false_when_no_author_can_be_found(): void
{
Http::fake([self::AUTHOR_PAGE => Http::response('<p>No h-card here</p>')]);
$this->assertFalse((new Authorship)->findAuthor($this->entryBy(self::AUTHOR_PAGE)));
$this->assertFalse((new Authorship)->findAuthor(['items' => [['type' => ['h-entry'], 'properties' => []]], 'rels' => []]));
}
#[Test]
public function it_throws_without_an_h_entry_or_h_feed(): void
{
$this->expectException(AuthorshipParserException::class);
(new Authorship)->findAuthor(['items' => [$this->hCard('Fred Bloggs')], 'rels' => []]);
}
#[Test]
public function it_throws_when_the_author_page_cannot_be_fetched(): void
{
Http::fake([self::AUTHOR_PAGE => Http::response('Not found', 404)]);
$this->expectException(AuthorshipParserException::class);
(new Authorship)->findAuthor($this->entryBy(self::AUTHOR_PAGE));
}
#[Test]
public function one_instance_can_be_reused_without_leaking_state(): void
{
$authorship = new Authorship;
$this->assertSame('Fred Bloggs', $authorship->findAuthor($this->entryBy('Fred Bloggs')));
$this->assertFalse($authorship->findAuthor(['items' => [['type' => ['h-entry'], 'properties' => []]], 'rels' => []]));
}
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\WebMention;
use App\Exceptions\InvalidMentionException;
use App\Services\WebMention\Parser;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class ParserTest extends TestCase
{
private const string TARGET = 'https://jonnybarnes.uk/notes/A';
private function entry(array $properties): array
{
return ['items' => [['type' => ['h-entry'], 'properties' => $properties]]];
}
public static function mentionTypes(): array
{
return [
'reply' => ['in-reply-to'],
'like' => ['like-of'],
'repost' => ['repost-of'],
];
}
#[Test]
#[DataProvider('mentionTypes')]
public function it_determines_the_mention_type(string $type): void
{
$this->assertSame($type, (new Parser)->getMentionType($this->entry([$type => [self::TARGET]])));
}
#[Test]
public function it_finds_the_mention_type_in_nested_microformats(): void
{
$mf = ['items' => [['type' => ['h-feed'], 'children' => [
['type' => ['h-entry'], 'properties' => ['like-of' => [self::TARGET]]],
]]]];
$this->assertSame('like-of', (new Parser)->getMentionType($mf));
}
#[Test]
public function it_throws_when_the_mention_type_cannot_be_determined(): void
{
$this->expectException(InvalidMentionException::class);
(new Parser)->getMentionType($this->entry(['content' => ['Just a post']]));
}
#[Test]
public function it_checks_a_reply_url_matches_the_target(): void
{
$parser = new Parser;
$this->assertTrue($parser->checkInReplyTo($this->entry(['in-reply-to' => [self::TARGET]]), self::TARGET));
$this->assertFalse($parser->checkInReplyTo($this->entry(['in-reply-to' => ['https://example.org/other']]), self::TARGET));
$this->assertFalse($parser->checkInReplyTo($this->entry(['content' => ['No reply']]), self::TARGET));
}
#[Test]
public function it_checks_a_reply_context_h_cite_matches_the_target(): void
{
$hCite = fn (string $url): array => ['type' => ['h-cite'], 'properties' => ['url' => [$url]]];
$parser = new Parser;
$this->assertTrue($parser->checkInReplyTo($this->entry(['in-reply-to' => [$hCite(self::TARGET)]]), self::TARGET));
$this->assertFalse($parser->checkInReplyTo($this->entry(['in-reply-to' => [$hCite('https://example.org/other')]]), self::TARGET));
}
#[Test]
public function it_checks_likes_and_reposts_match_the_target(): void
{
$parser = new Parser;
$this->assertTrue($parser->checkLikeOf($this->entry(['like-of' => [self::TARGET]]), self::TARGET));
$this->assertFalse($parser->checkLikeOf($this->entry(['like-of' => ['https://example.org/other']]), self::TARGET));
$this->assertFalse($parser->checkLikeOf($this->entry(['repost-of' => [self::TARGET]]), self::TARGET));
$this->assertTrue($parser->checkRepostOf($this->entry(['repost-of' => [self::TARGET]]), self::TARGET));
$this->assertFalse($parser->checkRepostOf($this->entry(['repost-of' => ['https://example.org/other']]), self::TARGET));
$this->assertFalse($parser->checkRepostOf($this->entry(['like-of' => [self::TARGET]]), self::TARGET));
}
}