diff --git a/app/Exceptions/AuthorshipParserException.php b/app/Exceptions/AuthorshipParserException.php new file mode 100644 index 00000000..7b3e418f --- /dev/null +++ b/app/Exceptions/AuthorshipParserException.php @@ -0,0 +1,9 @@ +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; + } +} diff --git a/app/Services/WebMention/Parser.php b/app/Services/WebMention/Parser.php new file mode 100644 index 00000000..e3e47a17 --- /dev/null +++ b/app/Services/WebMention/Parser.php @@ -0,0 +1,86 @@ +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; + } +} diff --git a/composer.json b/composer.json index c8213750..7d0010f7 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 9a65fed6..645efea7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "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", diff --git a/resources/views/projects.blade.php b/resources/views/projects.blade.php index 74eab728..62017fd6 100644 --- a/resources/views/projects.blade.php +++ b/resources/views/projects.blade.php @@ -22,6 +22,8 @@
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.
@stop diff --git a/tests/Feature/LikesTest.php b/tests/Feature/LikesTest.php index b08f0d37..5b270b46 100644 --- a/tests/Feature/LikesTest.php +++ b/tests/Feature/LikesTest.php @@ -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; diff --git a/tests/Unit/Jobs/ProcessWebMentionJobTest.php b/tests/Unit/Jobs/ProcessWebMentionJobTest.php index f7c9329e..daf3c404 100644 --- a/tests/Unit/Jobs/ProcessWebMentionJobTest.php +++ b/tests/Unit/Jobs/ProcessWebMentionJobTest.php @@ -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; diff --git a/tests/Unit/Jobs/SaveProfileImageJobTest.php b/tests/Unit/Jobs/SaveProfileImageJobTest.php index 8e0edbb4..4856f182 100644 --- a/tests/Unit/Jobs/SaveProfileImageJobTest.php +++ b/tests/Unit/Jobs/SaveProfileImageJobTest.php @@ -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; diff --git a/tests/Unit/Services/WebMention/AuthorshipTest.php b/tests/Unit/Services/WebMention/AuthorshipTest.php new file mode 100644 index 00000000..d4748b7d --- /dev/null +++ b/tests/Unit/Services/WebMention/AuthorshipTest.php @@ -0,0 +1,167 @@ + [[ + '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' + + + 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' + + + + 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' + + + 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( + '' + )]); + $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('No h-card here
')]); + + $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' => []])); + } +} diff --git a/tests/Unit/Services/WebMention/ParserTest.php b/tests/Unit/Services/WebMention/ParserTest.php new file mode 100644 index 00000000..8b7a7f50 --- /dev/null +++ b/tests/Unit/Services/WebMention/ParserTest.php @@ -0,0 +1,89 @@ + [['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)); + } +}