jonnybarnes.uk/app/Services/WebMention/Authorship.php
Jonny Barnes df9b0dc490
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>
2026-11-07 16:23:49 +00:00

152 lines
4.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}