jonnybarnes.uk/app/Models/Note.php
Jonny Barnes 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

415 lines
12 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\Models;
use App\CommonMark\Generators\MentionGenerator;
use App\CommonMark\Renderers\MentionRenderer;
use App\Observers\NoteObserver;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\IndieWeb\Numbers;
use Laravel\Scout\Searchable;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Mention\Mention;
use League\CommonMark\Extension\Mention\MentionExtension;
use League\CommonMark\MarkdownConverter;
use Normalizer;
use Tempest\Highlight\CommonMark\HighlightExtension;
#[Table('notes')]
#[Fillable(['note', 'in_reply_to', 'client_id'])]
#[Hidden(['searchable'])]
#[ObservedBy(NoteObserver::class)]
class Note extends Model
{
use HasFactory;
use Searchable;
use SoftDeletes;
/**
* The regex for matching lone usernames.
*
* @var string
*/
private const USERNAMES_REGEX = '/\[.*?\](*SKIP)(*F)|@(\w+)/';
/**
* This variable is used to keep track of contacts in a note.
*/
protected ?array $contacts;
/**
* Set our contacts variable to null.
*/
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->contacts = null;
}
/** @var string */
protected $table = 'notes';
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(MicropubClient::class, 'client_id', 'client_url');
}
public function webmentions(): MorphMany
{
return $this->morphMany(WebMention::class, 'commentable');
}
public function place(): BelongsTo
{
return $this->belongsTo(Place::class);
}
public function media(): BelongsToMany
{
return $this->BelongsToMany(Media::class)
->withPivot('alt_text', 'order')
->withTimestamps()
->orderBy('order');
}
/**
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'note' => $this->note,
];
}
public function setNoteAttribute(?string $value): void
{
if ($value !== null) {
$normalized = normalizer_normalize($value, Normalizer::FORM_C);
if ($normalized === '') { // we dont want to save empty strings to the db
$normalized = null;
}
$this->attributes['note'] = $normalized;
}
}
/**
* Pre-process notes for web-view.
*/
public function getNoteAttribute(?string $value): ?string
{
if ($value === null && $this->place !== null) {
$value = '📍: <a href="'.$this->place->uri.'">'.$this->place->name.'</a>';
}
// if $value is still null, just return null
if ($value === null) {
return null;
}
$hashtags = $this->autoLinkHashtag($value);
return $this->convertMarkdown($hashtags);
}
/**
* Provide the content_html for JSON feed.
*
* In particular, we want to include media links such as images.
*/
public function getContentAttribute(): string
{
$note = $this->getRawOriginal('note');
foreach ($this->media as $media) {
if ($media->type === 'image') {
$note .= PHP_EOL.'<img src="'.$media->url.'" alt="">';
}
if ($media->type === 'audio') {
$note .= PHP_EOL.'<audio src="'.$media->url.'">';
}
if ($media->type === 'video') {
$note .= PHP_EOL.'<video src="'.$media->url.'">';
}
}
if ($note === null) {
// when would $note still be blank?
$note = 'A blank note';
}
return $note;
}
public function getNb60idAttribute(): string
{
// we cast to string because sometimes the nb60id is an “int”
return (string) resolve(Numbers::class)->numto60($this->id);
}
public function getUriAttribute(): string
{
return config('app.url').'/notes/'.$this->nb60id;
}
public function getIso8601Attribute(): string
{
return $this->updated_at->toISO8601String();
}
public function getHumandiffAttribute(): string
{
return $this->updated_at->diffForHumans();
}
public function getPubdateAttribute(): string
{
return $this->updated_at->toRSSString();
}
public function getLatitudeAttribute(): ?float
{
if ($this->place !== null) {
return $this->place->latitude;
}
if ($this->location !== null) {
$pieces = explode(':', $this->location);
$latLng = explode(',', $pieces[0]);
return (float) trim($latLng[0]);
}
return null;
}
public function getLongitudeAttribute(): ?float
{
if ($this->place !== null) {
return $this->place->longitude;
}
if ($this->location !== null) {
$pieces = explode(':', $this->location);
$latLng = explode(',', $pieces[0]);
return (float) trim($latLng[1]);
}
return null;
}
/**
* Get the address for a note.
*
* This is either a reverse geo-code from the location, or is derived from the associated place.
*/
public function getAddressAttribute(): ?string
{
if ($this->place !== null) {
return $this->place->name;
}
if ($this->location !== null) {
return $this->reverseGeoCode((float) $this->latitude, (float) $this->longitude);
}
return null;
}
/**
* Scope a query to select a note via a NewBase60 id.
*/
public function scopeNb60(Builder $query, string $nb60id): Builder
{
$realId = resolve(Numbers::class)->b60tonum($nb60id);
// Check nb60 does not translate to ID too big for database int4 column
if ($realId > 2_147_483_647) {
abort(404);
}
return $query->where('id', resolve(Numbers::class)->b60tonum($nb60id));
}
/**
* Swap contacts nicks for a full mf2 h-card.
*
* Take note that this method does two things, given @username (NOT [@username](URL)!)
* we try to create a fancy hcard from our contact info. If this is not possible
* due to lack of contact info, we assume @username is a twitter handle and link it
* as such.
*/
private function makeHCards(string $text): string
{
$this->getContacts();
if (count($this->contacts) === 0) {
return $text;
}
return preg_replace_callback(
self::USERNAMES_REGEX,
function ($matches) {
if (is_null($this->contacts[$matches[1]])) {
return '<a href="https://twitter.com/'.$matches[1].'">'.$matches[0].'</a>';
}
$contact = $this->contacts[$matches[1]]; // easier to read the following code
$host = parse_url($contact->homepage, PHP_URL_HOST);
$contact->photo = '/assets/profile-images/default-image';
if (file_exists(public_path().'/assets/profile-images/'.$host.'/image')) {
$contact->photo = '/assets/profile-images/'.$host.'/image';
}
return trim(view('templates.mini-hcard', ['contact' => $contact])->render());
},
$text
);
}
/**
* Get the value of the `contacts` property.
*/
public function getContacts(): array
{
if ($this->contacts === null) {
$this->setContacts();
}
return $this->contacts;
}
/**
* Process the note and save the contacts to the `contacts` property.
*/
public function setContacts(): void
{
$contacts = [];
if ($this->getRawOriginal('note')) {
preg_match_all(self::USERNAMES_REGEX, $this->getRawOriginal('note'), $matches);
foreach ($matches[1] as $match) {
$contacts[$match] = Contact::where('nick', mb_strtolower($match))->first();
}
}
$this->contacts = $contacts;
}
/**
* Turn text hashtags to full HTML links.
*
* Given a string and section, finds all hashtags matching
* `#[\-_a-zA-Z0-9]+` and wraps them in an `a` element with
* `rel=tag` set and a `href` of 'section/tagged/' + tagname without the #.
*/
public function autoLinkHashtag(string $note): string
{
return preg_replace_callback(
'/#([^\s[:punct:]]+)/',
function ($matches) {
return '<a rel="tag" class="p-category" href="/notes/tagged/'
.Tag::normalize($matches[1]).'">#'
.$matches[1].'</a>';
},
$note
);
}
private function convertMarkdown(string $note): string
{
$config = [
'mentions' => [
'mentions_handle' => [
'prefix' => '@',
'pattern' => '([\w@.])+(\b)',
'generator' => new MentionGenerator,
],
],
];
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new AutolinkExtension);
$environment->addExtension(new MentionExtension);
$environment->addExtension(new HighlightExtension);
$environment->addRenderer(Mention::class, new MentionRenderer);
$markdownConverter = new MarkdownConverter($environment);
return $markdownConverter->convert($note)->getContent();
}
public function reverseGeoCode(float $latitude, float $longitude): string
{
$latLng = $latitude.','.$longitude;
return Cache::get($latLng, function () use ($latLng, $latitude, $longitude) {
$response = Http::withHeaders(['User-Agent' => 'jonnybarnes.uk, email jonny@jonnybarnes.uk'])
->get('https://nominatim.openstreetmap.org/reverse', [
'format' => 'json',
'lat' => $latitude,
'lon' => $longitude,
'zoom' => 18,
'addressdetails' => 1,
]);
$json = $response->object();
if (isset($json->address->suburb)) {
$locality = $json->address->suburb;
if (isset($json->address->city)) {
$locality .= ', '.$json->address->city;
}
$address = '<span class="p-locality">'
.$locality
.'</span>, <span class="p-country-name">'
.$json->address->country
.'</span>';
Cache::forever($latLng, $address);
return $address;
}
if (isset($json->address->city)) {
$address = '<span class="p-locality">'
.$json->address->city
.'</span>, <span class="p-country-name">'
.$json->address->country
.'</span>';
Cache::forever($latLng, $address);
return $address;
}
if (isset($json->address->county)) {
$address = '<span class="p-region">'
.$json->address->county
.'</span>, <span class="p-country-name">'
.$json->address->country
.'</span>';
Cache::forever($latLng, $address);
return $address;
}
$address = '<span class="p-country-name">'.$json->address->country.'</span>';
Cache::forever($latLng, $address);
return $address;
});
}
}