Upgrade to Laravel 13 #90

Merged
jonny merged 1 commit from laravel_13 into develop 2026-04-07 10:02:41 +02:00
117 changed files with 1878 additions and 2215 deletions

View file

@ -70,11 +70,6 @@ ADMIN_USER=admin# pick something better, this is used for `/admin`
ADMIN_PASS=password ADMIN_PASS=password
DISPLAY_NAME='Joe Bloggs'# This is used for example in the header and titles DISPLAY_NAME='Joe Bloggs'# This is used for example in the header and titles
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_ACCESS_TOKEN=
TWITTER_ACCESS_TOKEN_SECRET=
SCOUT_DRIVER=database SCOUT_DRIVER=database
SCOUT_QUEUE=false SCOUT_QUEUE=false

View file

@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Contact; use App\Models\Contact;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -103,7 +104,7 @@ class ContactsController extends Controller
* This method attempts to find the microformat marked-up profile image * This method attempts to find the microformat marked-up profile image
* from a given homepage and save it accordingly * from a given homepage and save it accordingly
* *
* @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View * @return RedirectResponse|View
*/ */
public function getAvatar(int $contactId) public function getAvatar(int $contactId)
{ {
@ -115,7 +116,7 @@ class ContactsController extends Controller
$client = resolve(Client::class); $client = resolve(Client::class);
try { try {
$response = $client->get($contact->homepage); $response = $client->get($contact->homepage);
} catch (\GuzzleHttp\Exception\BadResponseException $e) { } catch (BadResponseException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit') return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Bad resposne from contacts homepage'); ->with('error', 'Bad resposne from contacts homepage');
} }
@ -129,7 +130,7 @@ class ContactsController extends Controller
if ($avatarURL !== null) { if ($avatarURL !== null) {
try { try {
$avatar = $client->get($avatarURL); $avatar = $client->get($avatarURL);
} catch (\GuzzleHttp\Exception\BadResponseException $e) { } catch (BadResponseException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit') return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Unable to download avatar'); ->with('error', 'Unable to download avatar');
} }

View file

@ -128,7 +128,7 @@ class PasskeysController extends Controller
// Unset session data to mitigate replay attacks // Unset session data to mitigate replay attacks
$request->session()->forget('create_options'); $request->session()->forget('create_options');
if (empty($publicKeyCredentialCreationOptionsData)) { if (empty($publicKeyCredentialCreationOptionsData)) {
throw new WebAuthnException('No public key credential request options found'); throw new WebauthnException('No public key credential request options found');
} }
$attestationStatementSupportManager = new AttestationStatementSupportManager; $attestationStatementSupportManager = new AttestationStatementSupportManager;
@ -145,7 +145,7 @@ class PasskeysController extends Controller
); );
if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) { if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
throw new WebAuthnException('Invalid response type'); throw new WebauthnException('Invalid response type');
} }
$algorithmManager = new Manager; $algorithmManager = new Manager;

View file

@ -120,6 +120,7 @@ class PlacesController extends Controller
foreach ($place1->notes as $note) { foreach ($place1->notes as $note) {
$note->place()->dissociate(); $note->place()->dissociate();
$note->place()->associate($place2->id); $note->place()->associate($place2->id);
$note->save();
} }
$place1->delete(); $place1->delete();
} }
@ -127,6 +128,7 @@ class PlacesController extends Controller
foreach ($place2->notes as $note) { foreach ($place2->notes as $note) {
$note->place()->dissociate(); $note->place()->dissociate();
$note->place()->associate($place1->id); $note->place()->associate($place1->id);
$note->save();
} }
$place2->delete(); $place2->delete();
} }

View file

@ -16,7 +16,7 @@ class RedirectIfAuthenticated
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next, string ...$guards): Response public function handle(Request $request, Closure $next, string ...$guards): Response
{ {

View file

@ -5,10 +5,8 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use App\Models\Like; use App\Models\Like;
use Codebird\Codebird;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -39,35 +37,6 @@ class ProcessLike implements ShouldQueue
*/ */
public function handle(Client $client, Authorship $authorship): int public function handle(Client $client, Authorship $authorship): int
{ {
if ($this->isTweet($this->like->url)) {
$codebird = resolve(Codebird::class);
$tweet = $codebird->statuses_oembed(['url' => $this->like->url]);
$this->like->author_name = $tweet->author_name;
$this->like->author_url = $tweet->author_url;
$this->like->content = $tweet->html;
$this->like->save();
// POSSE like
try {
$client->request(
'POST',
'https://brid.gy/publish/webmention',
[
'form_params' => [
'source' => $this->like->url,
'target' => 'https://brid.gy/publish/twitter',
],
]
);
} catch (RequestException) {
return 0;
}
return 0;
}
$response = $client->request('GET', $this->like->url); $response = $client->request('GET', $this->like->url);
$mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url); $mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url);
if (Arr::has($mf2, 'items.0.properties.content')) { if (Arr::has($mf2, 'items.0.properties.content')) {
@ -91,15 +60,4 @@ class ProcessLike implements ShouldQueue
return 0; return 0;
} }
/**
* Determine if a given URL is that of a Tweet.
*/
private function isTweet(string $url): bool
{
$host = parse_url($url, PHP_URL_HOST);
$parts = array_reverse(explode('.', $host));
return $parts[0] === 'com' && $parts[1] === 'twitter';
}
} }

View file

@ -15,6 +15,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Mf2\Parser;
class SendWebMentions implements ShouldQueue class SendWebMentions implements ShouldQueue
{ {
@ -83,7 +84,11 @@ class SendWebMentions implements ShouldQueue
// failed to find a header so parse HTML // failed to find a header so parse HTML
$html = (string) $response->getBody(); $html = (string) $response->getBody();
$mf2 = new \Mf2\Parser($html, $url); if ($html === '') {
return null;
}
$mf2 = new Parser($html, $url);
$rels = $mf2->parseRelsAndAlternates(); $rels = $mf2->parseRelsAndAlternates();
if (array_key_exists('webmention', $rels[0])) { if (array_key_exists('webmention', $rels[0])) {
$endpoint = $rels[0]['webmention'][0]; $endpoint = $rels[0]['webmention'][0];

View file

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Cviebrock\EloquentSluggable\Sluggable; use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -18,23 +20,14 @@ use League\CommonMark\MarkdownConverter;
use Spatie\CommonMarkHighlighter\FencedCodeRenderer; use Spatie\CommonMarkHighlighter\FencedCodeRenderer;
use Spatie\CommonMarkHighlighter\IndentedCodeRenderer; use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
#[Table('articles')]
#[Fillable(['url', 'title', 'main', 'published'])]
class Article extends Model class Article extends Model
{ {
use HasFactory; use HasFactory;
use Sluggable; use Sluggable;
use SoftDeletes; use SoftDeletes;
/** @var string */
protected $table = 'articles';
/** @var array<int, string> */
protected $fillable = [
'url',
'title',
'main',
'published',
];
/** @var array<string, string> */ /** @var array<string, string> */
protected $casts = [ protected $casts = [
'created_at' => 'datetime', 'created_at' => 'datetime',

View file

@ -4,18 +4,17 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['url', 'name', 'content'])]
class Bookmark extends Model class Bookmark extends Model
{ {
use HasFactory; use HasFactory;
/** @var array<int, string> */
protected $fillable = ['url', 'name', 'content'];
/** @var array<string, string> */ /** @var array<string, string> */
protected $casts = [ protected $casts = [
'syndicates' => 'array', 'syndicates' => 'array',
@ -26,7 +25,7 @@ class Bookmark extends Model
return $this->belongsToMany('App\Models\Tag'); return $this->belongsToMany('App\Models\Tag');
} }
protected function local_uri(): Attribute protected function localUri(): Attribute
{ {
return Attribute::get( return Attribute::get(
get: fn () => config('app.url').'/bookmarks/'.$this->id, get: fn () => config('app.url').'/bookmarks/'.$this->id,

View file

@ -4,20 +4,18 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
#[Table('contacts')]
#[Fillable(['nick', 'name', 'homepage', 'twitter', 'facebook'])]
class Contact extends Model class Contact extends Model
{ {
use HasFactory; use HasFactory;
/** @var string */
protected $table = 'contacts';
/** @var array<int, string> */
protected $fillable = ['nick', 'name', 'homepage', 'twitter', 'facebook'];
protected function photo(): Attribute protected function photo(): Attribute
{ {
$photo = '/assets/profile-images/default-image'; $photo = '/assets/profile-images/default-image';

View file

@ -5,20 +5,19 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Traits\FilterHtml; use App\Traits\FilterHtml;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Mf2; use Mf2;
#[Fillable(['url'])]
class Like extends Model class Like extends Model
{ {
use FilterHtml; use FilterHtml;
use HasFactory; use HasFactory;
/** @var array<int, string> */
protected $fillable = ['url'];
protected function url(): Attribute protected function url(): Attribute
{ {
return Attribute::set( return Attribute::set(

View file

@ -4,22 +4,20 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Table('media_endpoint')]
#[Fillable(['token', 'path', 'type', 'image_widths'])]
class Media extends Model class Media extends Model
{ {
use HasFactory; use HasFactory;
/** @var string */
protected $table = 'media_endpoint';
/** @var array<int, string> */
protected $fillable = ['token', 'path', 'type', 'image_widths'];
public function notes(): BelongsToMany public function notes(): BelongsToMany
{ {
return $this->belongsToMany(Note::class) return $this->belongsToMany(Note::class)

View file

@ -4,20 +4,18 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Table('clients')]
#[Fillable(['client_url', 'client_name'])]
class MicropubClient extends Model class MicropubClient extends Model
{ {
use HasFactory; use HasFactory;
/** @var string */
protected $table = 'clients';
/** @var array<int, string> */
protected $fillable = ['client_url', 'client_name'];
public function notes(): HasMany public function notes(): HasMany
{ {
return $this->hasMany('App\Models\Note', 'client_id', 'client_url'); return $this->hasMany('App\Models\Note', 'client_id', 'client_url');

View file

@ -6,9 +6,12 @@ namespace App\Models;
use App\CommonMark\Generators\MentionGenerator; use App\CommonMark\Generators\MentionGenerator;
use App\CommonMark\Renderers\MentionRenderer; use App\CommonMark\Renderers\MentionRenderer;
use Codebird\Codebird; use App\Observers\NoteObserver;
use Exception;
use GuzzleHttp\Client; use GuzzleHttp\Client;
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\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -31,6 +34,10 @@ use Normalizer;
use Spatie\CommonMarkHighlighter\FencedCodeRenderer; use Spatie\CommonMarkHighlighter\FencedCodeRenderer;
use Spatie\CommonMarkHighlighter\IndentedCodeRenderer; use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
#[Table('notes')]
#[Fillable(['note', 'in_reply_to', 'client_id'])]
#[Hidden(['searchable'])]
#[ObservedBy(NoteObserver::class)]
class Note extends Model class Note extends Model
{ {
use HasFactory; use HasFactory;
@ -61,16 +68,6 @@ class Note extends Model
/** @var string */ /** @var string */
protected $table = 'notes'; protected $table = 'notes';
/** @var array<int, string> */
protected $fillable = [
'note',
'in_reply_to',
'client_id',
];
/** @var array<int, string> */
protected $hidden = ['searchable'];
public function tags(): BelongsToMany public function tags(): BelongsToMany
{ {
return $this->belongsToMany(Tag::class); return $this->belongsToMany(Tag::class);
@ -241,43 +238,6 @@ class Note extends Model
return null; return null;
} }
/**
* Get the OEmbed html for a tweet the note is a reply to.
*/
public function getTwitterAttribute(): ?object
{
if (
$this->in_reply_to === null ||
! $this->isTwitterLink($this->in_reply_to)
) {
return null;
}
$tweetId = basename($this->in_reply_to);
if (Cache::has($tweetId)) {
return Cache::get($tweetId);
}
try {
$codebird = resolve(Codebird::class);
$oEmbed = $codebird->statuses_oembed([
'url' => $this->in_reply_to,
'dnt' => true,
'align' => 'center',
'maxwidth' => 512,
]);
if ($oEmbed->httpstatus >= 400) {
throw new Exception;
}
} catch (Exception $e) {
return null;
}
Cache::put($tweetId, $oEmbed, ($oEmbed->cache_age));
return $oEmbed;
}
/** /**
* Scope a query to select a note via a NewBase60 id. * Scope a query to select a note via a NewBase60 id.
*/ */
@ -459,9 +419,4 @@ class Note extends Model
return $address; return $address;
}); });
} }
private function isTwitterLink(string $inReplyTo): bool
{
return str_starts_with($inReplyTo, 'https://twitter.com/');
}
} }

View file

@ -4,20 +4,16 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['passkey_id', 'passkey'])]
class Passkey extends Model class Passkey extends Model
{ {
use HasFactory; use HasFactory;
/** @inerhitDoc */
protected $fillable = [
'passkey_id',
'passkey',
];
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Cviebrock\EloquentSluggable\Sluggable; use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable(['name', 'slug'])]
class Place extends Model class Place extends Model
{ {
use HasFactory; use HasFactory;
@ -22,9 +24,6 @@ class Place extends Model
return 'slug'; return 'slug';
} }
/** @var array<int, string> */
protected $fillable = ['name', 'slug'];
/** @var array<string, string> */ /** @var array<string, string> */
protected $casts = [ protected $casts = [
'latitude' => 'float', 'latitude' => 'float',

View file

@ -4,40 +4,20 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
#[Fillable(['uid', 'name', 'service_name', 'service_url', 'service_photo', 'user_name', 'user_url', 'user_photo'])]
#[Visible(['uid', 'name', 'service', 'user'])]
#[Appends(['service', 'user'])]
class SyndicationTarget extends Model class SyndicationTarget extends Model
{ {
use HasFactory; use HasFactory;
/** @var array<int, string> */
protected $fillable = [
'uid',
'name',
'service_name',
'service_url',
'service_photo',
'user_name',
'user_url',
'user_photo',
];
/** @var array<int, string> */
protected $visible = [
'uid',
'name',
'service',
'user',
];
/** @var array<int, string> */
protected $appends = [
'service',
'user',
];
protected function service(): Attribute protected function service(): Attribute
{ {
return Attribute::get( return Attribute::get(

View file

@ -4,19 +4,18 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Guarded(['id'])]
class Tag extends Model class Tag extends Model
{ {
use HasFactory; use HasFactory;
/** @var array<int, string> */
protected $guarded = ['id'];
public function notes(): BelongsToMany public function notes(): BelongsToMany
{ {
return $this->belongsToMany(Note::class); return $this->belongsToMany(Note::class);

View file

@ -4,27 +4,34 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {
use HasFactory; /** @use HasFactory<UserFactory> */
use Notifiable; use HasFactory, Notifiable;
/** @var array<int, string> */ /**
protected $fillable = [ * Get the attributes that should be cast.
'name', 'password', *
]; * @return array<string, string>
*/
/** @var array<int, string> */ protected function casts(): array
protected $hidden = [ {
'current_password', return [
'password', 'email_verified_at' => 'datetime',
'remember_token', 'password' => 'hashed',
]; ];
}
public function passkey(): HasMany public function passkey(): HasMany
{ {

View file

@ -5,27 +5,23 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Traits\FilterHtml; use App\Traits\FilterHtml;
use Codebird\Codebird;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Authorship;
#[Table('webmentions')]
#[Guarded(['id'])]
class WebMention extends Model class WebMention extends Model
{ {
use FilterHtml; use FilterHtml;
use HasFactory; use HasFactory;
/** @var string */
protected $table = 'webmentions';
/** @var array<int, string> */
protected $guarded = ['id'];
public function commentable(): MorphTo public function commentable(): MorphTo
{ {
return $this->morphTo(); return $this->morphTo();
@ -127,19 +123,6 @@ class WebMention extends Model
return str_replace('http://', 'https://', $url); return str_replace('http://', 'https://', $url);
} }
if ($host === 'twitter.com') {
if (Cache::has($url)) {
return Cache::get($url);
}
$username = ltrim(parse_url($url, PHP_URL_PATH), '/');
$codebird = resolve(Codebird::class);
$info = $codebird->users_show(['screen_name' => $username]);
$profile_image = $info->profile_image_url_https;
Cache::put($url, $profile_image, 10080); // 1 week
return $profile_image;
}
$filesystem = new Filesystem; $filesystem = new Filesystem;
if ($filesystem->exists(public_path().'/assets/profile-images/'.$host.'/image')) { if ($filesystem->exists(public_path().'/assets/profile-images/'.$host.'/image')) {
return '/assets/profile-images/'.$host.'/image'; return '/assets/profile-images/'.$host.'/image';

View file

@ -2,15 +2,15 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Note;
use App\Observers\NoteObserver;
use Codebird\Codebird;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware; use GuzzleHttp\Middleware;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Intervention\Image\ImageManager;
use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Key\InMemory;
@ -33,33 +33,11 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
Note::observe(NoteObserver::class);
// configure Intervention/Image // configure Intervention/Image
$this->app->bind('Intervention\Image\ImageManager', function () { $this->app->bind('Intervention\Image\ImageManager', function () {
return \Intervention\Image\ImageManager::withDriver(config('image.driver')); return ImageManager::withDriver(config('image.driver'));
}); });
// Bind the Codebird client
// Codebird gets mocked in tests
// @codeCoverageIgnoreStart
$this->app->bind('Codebird\Codebird', function () {
Codebird::setConsumerKey(
env('TWITTER_CONSUMER_KEY'),
env('TWITTER_CONSUMER_SECRET')
);
$cb = Codebird::getInstance();
$cb->setToken(
env('TWITTER_ACCESS_TOKEN'),
env('TWITTER_ACCESS_TOKEN_SECRET')
);
return $cb;
});
// @codeCoverageIgnoreEnd
/** /**
* Paginate a standard Laravel Collection. * Paginate a standard Laravel Collection.
* *
@ -106,7 +84,7 @@ class AppServiceProvider extends ServiceProvider
// Configure Guzzle // Configure Guzzle
$this->app->bind('RetryGuzzle', function () { $this->app->bind('RetryGuzzle', function () {
$handlerStack = \GuzzleHttp\HandlerStack::create(); $handlerStack = HandlerStack::create();
$handlerStack->push(Middleware::retry( $handlerStack->push(Middleware::retry(
function ($retries, $request, $response, $exception) { function ($retries, $request, $response, $exception) {
// Limit the number of retries to 5 // Limit the number of retries to 5
@ -115,7 +93,7 @@ class AppServiceProvider extends ServiceProvider
} }
// Retry connection exceptions // Retry connection exceptions
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) { if ($exception instanceof ConnectException) {
return true; return true;
} }

View file

@ -15,14 +15,17 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware $middleware
->append(LinkHeadersMiddleware::class) ->append(LinkHeadersMiddleware::class)
->validateCsrfTokens(except: [ ->preventRequestForgery(
except: [
'auth', // This is the IndieAuth auth endpoint 'auth', // This is the IndieAuth auth endpoint
'token', // This is the IndieAuth token endpoint 'token', // This is the IndieAuth token endpoint
'api/post', 'api/post',
'api/media', 'api/media',
'micropub/places', 'micropub/places',
'webmention', 'webmention',
]); ],
originOnly: true
);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
Flare::handles($exceptions); Flare::handles($exceptions);

View file

@ -1,7 +1,11 @@
<?php <?php
use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\MicropubServiceProvider;
return [ return [
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class, HorizonServiceProvider::class,
App\Providers\MicropubServiceProvider::class, MicropubServiceProvider::class,
]; ];

View file

@ -6,34 +6,30 @@
"keywords": ["laravel", "framework", "indieweb"], "keywords": ["laravel", "framework", "indieweb"],
"license": "CC0-1.0", "license": "CC0-1.0",
"require": { "require": {
"php": "^8.2", "php": "^8.3",
"ext-dom": "*", "ext-dom": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-json": "*", "ext-json": "*",
"ext-pgsql": "*", "ext-pgsql": "*",
"ext-pdo": "*",
"ext-sodium": "*", "ext-sodium": "*",
"cviebrock/eloquent-sluggable": "^12.0", "cviebrock/eloquent-sluggable": "^13.0",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"indieauth/client": "^1.1", "indieauth/client": "^1.1",
"intervention/image": "^3", "intervention/image": "^3",
"jonnybarnes/indieweb": "~0.2", "jonnybarnes/indieweb": "~0.2",
"jonnybarnes/webmentions-parser": "~0.5", "jonnybarnes/webmentions-parser": "~0.5",
"jublonet/codebird-php": "4.0.0-beta.1", "laravel/framework": "^13.0",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.0", "laravel/horizon": "^5.0",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.1", "laravel/scout": "^10.1",
"laravel/tinker": "^2.8", "laravel/tinker": "^3.0",
"lcobucci/jwt": "^5.0", "lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0", "league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3", "mf2/mf2": "~0.3",
"phpdocumentor/reflection-docblock": "^5.3",
"spatie/commonmark-highlighter": "^3.0", "spatie/commonmark-highlighter": "^3.0",
"spatie/laravel-flare": "^2.2", "spatie/laravel-flare": "^2.2",
"symfony/html-sanitizer": "^7.0", "symfony/html-sanitizer": "^8.0",
"symfony/property-access": "^7.0",
"symfony/serializer": "^7.0",
"web-auth/webauthn-lib": "^5.0" "web-auth/webauthn-lib": "^5.0"
}, },
"require-dev": { "require-dev": {
@ -41,13 +37,13 @@
"barryvdh/laravel-ide-helper": "^3.0", "barryvdh/laravel-ide-helper": "^3.0",
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/dusk": "^8.0", "laravel/dusk": "^8.0",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.5",
"laravel/pint": "^1.24", "laravel/pint": "^1.27",
"laravel/sail": "^1.41", "laravel/sail": "^1.41",
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"phpunit/php-code-coverage": "^11.0", "phpunit/php-code-coverage": "^12.0",
"phpunit/phpunit": "^11.5.50", "phpunit/phpunit": "^12.5.12",
"spatie/laravel-ray": "^1.12", "spatie/laravel-ray": "^1.12",
"spatie/x-ray": "^1.2" "spatie/x-ray": "^1.2"
}, },
@ -72,7 +68,7 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate", "@php artisan key:generate",
"@php artisan migrate --force", "@php artisan migrate --force",
"npm install", "npm install --ignore-scripts",
"npm run build" "npm run build"
], ],
"dev": [ "dev": [

2245
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Models\User;
return [ return [
/* /*
@ -62,7 +64,7 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', User::class),
], ],
// 'users' => [ // 'users' => [

View file

@ -41,9 +41,10 @@ return [
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'table' => env('DB_CACHE_TABLE', 'cache'),
'connection' => env('DB_CACHE_CONNECTION'), 'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
], ],
'file' => [ 'file' => [
@ -111,6 +112,19 @@ return [
| |
*/ */
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'_cache_'), 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
]; ];

View file

@ -1,6 +1,7 @@
<?php <?php
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Pdo\Mysql;
return [ return [
@ -59,7 +60,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@ -79,7 +80,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@ -95,7 +96,7 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => 'prefer', 'sslmode' => env('DB_SSLMODE', 'prefer'),
], ],
'sqlsrv' => [ 'sqlsrv' => [
@ -148,7 +149,7 @@ return [
'options' => [ 'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'), 'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'_database_'), 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false), 'persistent' => env('REDIS_PERSISTENT', false),
], ],

View file

@ -24,7 +24,7 @@ return [
| may even configure multiple disks for the same driver. Examples for | may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference. | most supported storage drivers are configured here for reference.
| |
| Supported Drivers: "local", "ftp", "sftp", "s3" | Supported drivers: "local", "ftp", "sftp", "s3"
| |
*/ */
@ -41,7 +41,7 @@ return [
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), 'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL'), '/').'/storage', 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public', 'visibility' => 'public',
'throw' => false, 'throw' => false,
'report' => false, 'report' => false,

View file

@ -1,8 +1,10 @@
<?php <?php
use Spatie\FlareClient\Api; use Spatie\FlareClient\Api;
use Spatie\FlareClient\Sampling\RateSampler;
use Spatie\LaravelFlare\AttributesProviders\LaravelUserAttributesProvider; use Spatie\LaravelFlare\AttributesProviders\LaravelUserAttributesProvider;
use Spatie\LaravelFlare\FlareConfig; use Spatie\LaravelFlare\FlareConfig;
use Spatie\LaravelFlare\Senders\LaravelHttpSender;
return [ return [
/* /*
@ -152,7 +154,7 @@ return [
*/ */
'sender' => [ 'sender' => [
'class' => \Spatie\LaravelFlare\Senders\LaravelHttpSender::class, 'class' => LaravelHttpSender::class,
'config' => [ 'config' => [
'timeout' => 10, 'timeout' => 10,
], ],
@ -182,7 +184,7 @@ return [
| |
*/ */
'sampler' => [ 'sampler' => [
'class' => \Spatie\FlareClient\Sampling\RateSampler::class, 'class' => RateSampler::class,
'config' => [ 'config' => [
'rate' => 0.1, 'rate' => 0.1,
], ],

View file

@ -1,6 +1,7 @@
<?php <?php
use Monolog\Handler\NullHandler; use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler; use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor; use Monolog\Processor\PsrLogMessageProcessor;
@ -44,7 +45,7 @@ return [
| utilizes the Monolog PHP logging library, which includes a variety | utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use. | of powerful log handlers and formatters that you're free to use.
| |
| Available Drivers: "single", "daily", "slack", "syslog", | Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack" | "errorlog", "monolog", "custom", "stack"
| |
*/ */
@ -75,7 +76,7 @@ return [
'slack' => [ 'slack' => [
'driver' => 'slack', 'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'), 'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'), 'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true, 'replace_placeholders' => true,
@ -96,6 +97,7 @@ return [
'stderr' => [ 'stderr' => [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [ 'handler_with' => [
'stream' => 'php://stderr', 'stream' => 'php://stderr',
], ],

View file

@ -112,7 +112,7 @@ return [
'from' => [ 'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'), 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
], ],
]; ];

View file

@ -1,67 +0,0 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. If this value is null, personal access tokens do
| not expire. This won't tweak the lifetime of first-party sessions.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
],
];

View file

@ -214,4 +214,20 @@ return [
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
]; ];

View file

@ -1,18 +0,0 @@
<?php
// You can find the keys here : https://dev.twitter.com/
return [
'API_URL' => 'api.twitter.com',
'API_VERSION' => '1.1',
'AUTHENTICATE_URL' => 'https://api.twitter.com/oauth/authenticate',
'AUTHORIZE_URL' => 'https://api.twitter.com/oauth/authorize',
'ACCESS_TOKEN_URL' => 'oauth/access_token',
'REQUEST_TOKEN_URL' => 'oauth/request_token',
'USE_SSL' => true,
'CONSUMER_KEY' => env('TWITTER_CONSUMER_KEY'),
'CONSUMER_SECRET' => env('TWITTER_CONSUMER_SECRET'),
'ACCESS_TOKEN' => env('TWITTER_ACCESS_TOKEN'),
'ACCESS_TOKEN_SECRET' => env('TWITTER_ACCESS_TOKEN_SECRET'),
];

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article> * @extends Factory<Article>
*/ */
class ArticleFactory extends Factory class ArticleFactory extends Factory
{ {

View file

@ -2,10 +2,11 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Bio;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bio> * @extends Factory<Bio>
*/ */
class BioFactory extends Factory class BioFactory extends Factory
{ {

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bookmark> * @extends Factory<Bookmark>
*/ */
class BookmarkFactory extends Factory class BookmarkFactory extends Factory
{ {

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Contact> * @extends Factory<Contact>
*/ */
class ContactFactory extends Factory class ContactFactory extends Factory
{ {

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Like> * @extends Factory<Like>
*/ */
class LikeFactory extends Factory class LikeFactory extends Factory
{ {

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Media> * @extends Factory<Media>
*/ */
class MediaFactory extends Factory class MediaFactory extends Factory
{ {

View file

@ -6,7 +6,7 @@ use App\Models\MicropubClient;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\MicropubClient> * @extends Factory<MicropubClient>
*/ */
class MicropubClientFactory extends Factory class MicropubClientFactory extends Factory
{ {

View file

@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Note> * @extends Factory<Note>
*/ */
class NoteFactory extends Factory class NoteFactory extends Factory
{ {

View file

@ -6,7 +6,7 @@ use App\Models\Passkey;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Passkey> * @extends Factory<Passkey>
*/ */
class PasskeyFactory extends Factory class PasskeyFactory extends Factory
{ {

View file

@ -6,7 +6,7 @@ use App\Models\Place;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Place> * @extends Factory<Place>
*/ */
class PlaceFactory extends Factory class PlaceFactory extends Factory
{ {

View file

@ -2,10 +2,11 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\SyndicationTarget;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SyndicationTarget> * @extends Factory<SyndicationTarget>
*/ */
class SyndicationTargetFactory extends Factory class SyndicationTargetFactory extends Factory
{ {

View file

@ -6,7 +6,7 @@ use App\Models\Tag;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Tag> * @extends Factory<Tag>
*/ */
class TagFactory extends Factory class TagFactory extends Factory
{ {

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> * @extends Factory<User>
*/ */
class UserFactory extends Factory class UserFactory extends Factory
{ {

View file

@ -6,7 +6,7 @@ use App\Models\WebMention;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WebMention> * @extends Factory<WebMention>
*/ */
class WebMentionFactory extends Factory class WebMentionFactory extends Factory
{ {

View file

@ -4,6 +4,9 @@ namespace Database\Seeders;
use App\Models\Like; use App\Models\Like;
use Faker\Generator; use Faker\Generator;
use Faker\Provider\en_US\Person;
use Faker\Provider\Internet;
use Faker\Provider\Lorem;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -19,9 +22,9 @@ class LikesTableSeeder extends Seeder
$now = Carbon::now()->subDays(rand(3, 6)); $now = Carbon::now()->subDays(rand(3, 6));
$faker = new Generator; $faker = new Generator;
$faker->addProvider(new \Faker\Provider\en_US\Person($faker)); $faker->addProvider(new Person($faker));
$faker->addProvider(new \Faker\Provider\Lorem($faker)); $faker->addProvider(new Lorem($faker));
$faker->addProvider(new \Faker\Provider\Internet($faker)); $faker->addProvider(new Internet($faker));
$likeFromAuthor = Like::create([ $likeFromAuthor = Like::create([
'url' => $faker->url, 'url' => $faker->url,
'author_url' => $faker->url, 'author_url' => $faker->url,

View file

@ -1,6 +1,3 @@
{ {
"preset": "laravel", "preset": "laravel"
"rules": {
"concat_space": false
}
} }

View file

@ -3,7 +3,7 @@
@section('title')Merge Places « Admin CP « @stop @section('title')Merge Places « Admin CP « @stop
@section('content') @section('content')
<p>We shall be merging {{ $first->name }}. Its location is <code>Point({{ $first->location }})</code>.</p> <p>We shall be merging {{ $first->name }}. Its location is <code>{{ $first->latitude }}, {{ $first->longitude }}</code>.</p>
<ul> <ul>
@foreach($places as $place) @foreach($places as $place)
<li> <li>

View file

@ -7,15 +7,15 @@
@foreach($bookmarks as $bookmark) @foreach($bookmarks as $bookmark)
<div class="h-entry"> <div class="h-entry">
<div class="bookmark-link"> <div class="bookmark-link">
<a class="u-bookmark-of<?php if ($bookmark->name !== null) { echo ' h-cite'; } ?>" href="{{ $bookmark->uri }}"> <a class="u-bookmark-of<?php if ($bookmark->name !== null) { echo ' h-cite'; } ?>" href="{{ $bookmark->url }}">
@isset($bookmark->name) @isset($bookmark->name)
{{ $bookmark->name }} {{ $bookmark->name }}
@endisset @endisset
@empty($bookmark->name) @empty($bookmark->name)
{{ $bookmark->uri }} {{ $bookmark->url }}
@endempty @endempty
</a> &nbsp; <a href="{{ $bookmark->uri }}">🔗</a> </a> &nbsp; <a href="{{ $bookmark->url }}">🔗</a>
</div> </div>
@isset($bookmark->content) @isset($bookmark->content)
<p>{{ $bookmark->content }}</p> <p>{{ $bookmark->content }}</p>

View file

@ -8,15 +8,15 @@
@foreach($bookmarks as $bookmark) @foreach($bookmarks as $bookmark)
<div class="h-entry"> <div class="h-entry">
<div class="bookmark-link"> <div class="bookmark-link">
<a class="u-bookmark-of<?php if ($bookmark->name !== null) { echo ' h-cite'; } ?>" href="{{ $bookmark->uri }}"> <a class="u-bookmark-of<?php if ($bookmark->name !== null) { echo ' h-cite'; } ?>" href="{{ $bookmark->url }}">
@isset($bookmark->name) @isset($bookmark->name)
{{ $bookmark->name }} {{ $bookmark->name }}
@endisset @endisset
@empty($bookmark->name) @empty($bookmark->name)
{{ $bookmark->uri }} {{ $bookmark->url }}
@endempty @endempty
</a> &nbsp; <a href="{{ $bookmark->uri }}">🔗</a> </a> &nbsp; <a href="{{ $bookmark->url }}">🔗</a>
</div> </div>
@isset($bookmark->content) @isset($bookmark->content)
<p>{{ $bookmark->content }}</p> <p>{{ $bookmark->content }}</p>

View file

@ -1,7 +1,5 @@
<div class="h-entry"> <div class="h-entry">
@if ($note->twitter) @if ($note->in_reply_to)
{!! $note->twitter->html !!}
@elseif ($note->in_reply_to)
<div class="u-in-reply-to h-cite reply-to"> <div class="u-in-reply-to h-cite reply-to">
In reply to <a href="{{ $note->in_reply_to }}" class="u-url">{{ $note->in_reply_to }}</a> In reply to <a href="{{ $note->in_reply_to }}" class="u-url">{{ $note->in_reply_to }}</a>
</div> </div>

View file

@ -26,7 +26,7 @@ abstract class DuskTestCase extends BaseTestCase
/** /**
* Create the RemoteWebDriver instance. * Create the RemoteWebDriver instance.
* *
* @return \Facebook\WebDriver\Remote\RemoteWebDriver * @return RemoteWebDriver
*/ */
protected function driver() protected function driver()
{ {

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature\Admin; namespace Tests\Feature\Admin;
use App\Models\Note;
use App\Models\Place; use App\Models\Place;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -78,4 +79,62 @@ class PlacesTest extends TestCase
'name' => 'The Bridgewater', 'name' => 'The Bridgewater',
]); ]);
} }
#[Test]
public function merge_index_page_loads(): void
{
$user = User::factory()->make();
// Use specific coordinates to avoid haversine acos overflow with extreme faker values
$place = Place::factory()->create(['latitude' => 53.48, 'longitude' => -2.24]);
$response = $this->actingAs($user)->get('/admin/places/'.$place->id.'/merge');
$response->assertViewIs('admin.places.merge.index');
}
#[Test]
public function merge_edit_page_loads(): void
{
$user = User::factory()->make();
$place1 = Place::factory()->create();
$place2 = Place::factory()->create();
$response = $this->actingAs($user)->get('/admin/places/'.$place1->id.'/merge/'.$place2->id);
$response->assertViewIs('admin.places.merge.edit');
}
#[Test]
public function merge_store_with_delete_one_moves_notes_and_deletes_place1(): void
{
$user = User::factory()->make();
$place1 = Place::factory()->create();
$place2 = Place::factory()->create();
$note = Note::factory()->create(['place_id' => $place1->id]);
$this->actingAs($user)->post('/admin/places/merge', [
'place1' => $place1->id,
'place2' => $place2->id,
'delete' => '1',
]);
$this->assertDatabaseMissing('places', ['id' => $place1->id]);
$this->assertDatabaseHas('notes', ['id' => $note->id, 'place_id' => $place2->id]);
}
#[Test]
public function merge_store_with_delete_two_moves_notes_and_deletes_place2(): void
{
$user = User::factory()->make();
$place1 = Place::factory()->create();
$place2 = Place::factory()->create();
$note = Note::factory()->create(['place_id' => $place2->id]);
$this->actingAs($user)->post('/admin/places/merge', [
'place1' => $place1->id,
'place2' => $place2->id,
'delete' => '2',
]);
$this->assertDatabaseMissing('places', ['id' => $place2->id]);
$this->assertDatabaseHas('notes', ['id' => $note->id, 'place_id' => $place1->id]);
}
} }

View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\SyndicationTarget;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SyndicationTargetsTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function index_requires_authentication(): void
{
$response = $this->get('/admin/syndication');
$response->assertRedirect();
}
#[Test]
public function index_lists_syndication_targets(): void
{
$user = User::factory()->make();
$target = SyndicationTarget::factory()->create();
$response = $this->actingAs($user)->get('/admin/syndication');
$response->assertOk();
$response->assertSeeText($target->uid);
}
#[Test]
public function create_page_loads(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->get('/admin/syndication/create');
$response->assertOk();
}
#[Test]
public function store_creates_a_new_syndication_target(): void
{
$user = User::factory()->make();
$this->actingAs($user)->post('/admin/syndication', [
'uid' => 'https://mastodon.social/users/me',
'name' => 'Mastodon',
]);
$this->assertDatabaseHas('syndication_targets', [
'uid' => 'https://mastodon.social/users/me',
'name' => 'Mastodon',
]);
}
#[Test]
public function store_redirects_to_index_after_creation(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->post('/admin/syndication', [
'uid' => 'https://mastodon.social/users/me',
'name' => 'Mastodon',
]);
$response->assertRedirect('/admin/syndication');
}
#[Test]
public function edit_page_loads_with_target_data(): void
{
$user = User::factory()->make();
$target = SyndicationTarget::factory()->create(['name' => 'My Mastodon']);
$response = $this->actingAs($user)->get("/admin/syndication/{$target->id}/edit");
$response->assertOk();
$response->assertSee('value="My Mastodon"', false);
}
#[Test]
public function update_modifies_syndication_target(): void
{
$user = User::factory()->make();
$target = SyndicationTarget::factory()->create(['name' => 'Old Name']);
$this->actingAs($user)->put("/admin/syndication/{$target->id}", [
'uid' => $target->uid,
'name' => 'New Name',
]);
$this->assertDatabaseHas('syndication_targets', [
'id' => $target->id,
'name' => 'New Name',
]);
}
#[Test]
public function update_redirects_to_index(): void
{
$user = User::factory()->make();
$target = SyndicationTarget::factory()->create();
$response = $this->actingAs($user)->put("/admin/syndication/{$target->id}", [
'uid' => $target->uid,
'name' => 'Updated Name',
]);
$response->assertRedirect('/admin/syndication');
}
#[Test]
public function destroy_deletes_syndication_target(): void
{
$user = User::factory()->make();
$target = SyndicationTarget::factory()->create();
$this->actingAs($user)->delete("/admin/syndication/{$target->id}");
$this->assertDatabaseMissing('syndication_targets', ['id' => $target->id]);
}
#[Test]
public function destroy_redirects_to_index(): void
{
$user = User::factory()->make();
$target = SyndicationTarget::factory()->create();
$response = $this->actingAs($user)->delete("/admin/syndication/{$target->id}");
$response->assertRedirect('/admin/syndication');
}
}

View file

@ -6,6 +6,7 @@ namespace Tests\Feature;
use App\Jobs\ProcessBookmark; use App\Jobs\ProcessBookmark;
use App\Models\Bookmark; use App\Models\Bookmark;
use App\Models\Tag;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@ -69,6 +70,27 @@ class BookmarksTest extends TestCase
$this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']);
} }
#[Test]
public function tagged_bookmarks_page_only_shows_bookmarks_with_that_tag(): void
{
$tag = Tag::factory()->create(['tag' => 'php']);
$tagged = Bookmark::factory()->create();
$tagged->tags()->attach($tag);
$untagged = Bookmark::factory()->create();
$response = $this->get('/bookmarks/tagged/php');
$response->assertViewIs('bookmarks.tagged');
$response->assertSee($tagged->url);
$response->assertDontSee($untagged->url);
}
#[Test]
public function bookmark_local_uri_attribute_returns_correct_url(): void
{
$bookmark = Bookmark::factory()->create();
$this->assertEquals(config('app.url').'/bookmarks/'.$bookmark->id, $bookmark->local_uri);
}
#[Test] #[Test]
public function when_the_bookmark_is_created_check_necessary_tags_are_also_created(): void public function when_the_bookmark_is_created_check_necessary_tags_are_also_created(): void
{ {

View file

@ -6,7 +6,6 @@ namespace Tests\Feature;
use App\Jobs\ProcessLike; use App\Jobs\ProcessLike;
use App\Models\Like; use App\Models\Like;
use Codebird\Codebird;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack; use GuzzleHttp\HandlerStack;
@ -194,82 +193,6 @@ class LikesTest extends TestCase
$this->assertNull(Like::find($id)->author_name); $this->assertNull(Like::find($id)->author_name);
} }
#[Test]
public function like_that_is_a_tweet(): void
{
$like = new Like;
$like->url = 'https://twitter.com/jonnybarnes/status/1050823255123251200';
$like->save();
$id = $like->id;
$job = new ProcessLike($like);
$mock = new MockHandler([
new Response(201, [], json_encode([
'url' => 'https://twitter.com/likes/id',
])),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$info = (object) [
'author_name' => 'Jonny Barnes',
'author_url' => 'https://twitter.com/jonnybarnes',
'html' => '<div>HTML of the tweet embed</div>',
];
$codebirdMock = $this->createPartialMock(Codebird::class, ['__call']);
$codebirdMock->method('__call')
->with('statuses_oembed', $this->anything())
->willReturn($info);
$this->app->instance(Codebird::class, $codebirdMock);
$authorship = new Authorship;
$job->handle($client, $authorship);
$this->assertEquals('Jonny Barnes', Like::find($id)->author_name);
}
#[Test]
public function no_error_for_failure_to_posse_with_bridgy(): void
{
$like = new Like;
$like->url = 'https://twitter.com/jonnybarnes/status/1050823255123251200';
$like->save();
$id = $like->id;
$job = new ProcessLike($like);
$mock = new MockHandler([
new Response(404, [], 'Not found'),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$info = (object) [
'author_name' => 'Jonny Barnes',
'author_url' => 'https://twitter.com/jonnybarnes',
'html' => '<div>HTML of the tweet embed</div>',
];
$codebirdMock = $this->createPartialMock(Codebird::class, ['__call']);
$codebirdMock->method('__call')
->with('statuses_oembed', $this->anything())
->willReturn($info);
$this->app->instance(Codebird::class, $codebirdMock);
$authorship = new Authorship;
$job->handle($client, $authorship);
$this->assertEquals('Jonny Barnes', Like::find($id)->author_name);
}
#[Test] #[Test]
public function unknown_like_gives_not_found_response(): void public function unknown_like_gives_not_found_response(): void
{ {

View file

@ -820,7 +820,7 @@ class MicropubControllerTest extends TestCase
public function micropub_client_api_request_can_delete_photo(): void public function micropub_client_api_request_can_delete_photo(): void
{ {
$note = Note::factory()->create(); $note = Note::factory()->create();
$media = new \App\Models\Media; $media = new Media;
$media->path = 'https://example.org/photo.jpg'; $media->path = 'https://example.org/photo.jpg';
$media->type = 'image'; $media->type = 'image';
$media->save(); $media->save();

View file

@ -7,6 +7,7 @@ namespace Tests\Unit\Jobs;
use App\Jobs\SaveScreenshot; use App\Jobs\SaveScreenshot;
use App\Models\Bookmark; use App\Models\Bookmark;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack; use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware; use GuzzleHttp\Middleware;
@ -45,7 +46,7 @@ class SaveScreenshotJobTest extends TestCase
} }
// Retry connection exceptions // Retry connection exceptions
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) { if ($exception instanceof ConnectException) {
return true; return true;
} }
@ -115,7 +116,7 @@ class SaveScreenshotJobTest extends TestCase
} }
// Retry connection exceptions // Retry connection exceptions
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) { if ($exception instanceof ConnectException) {
return true; return true;
} }

View file

@ -7,6 +7,7 @@ namespace Tests\Unit;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -38,4 +39,68 @@ class MediaTest extends TestCase
$this->assertEquals($absoluteUrl, $media->url); $this->assertEquals($absoluteUrl, $media->url);
} }
#[Test]
public function local_paths_get_storage_url_prepended(): void
{
$media = new Media;
$media->path = 'photo.jpg';
$this->assertEquals(config('app.url').'/storage/photo.jpg', $media->url);
}
#[Test]
public function medium_url_returns_resized_path(): void
{
$media = new Media;
$media->path = 'photo.jpg';
$this->assertEquals(config('app.url').'/storage/photo-medium.jpg', $media->mediumurl);
}
#[Test]
public function small_url_returns_resized_path(): void
{
$media = new Media;
$media->path = 'photo.jpg';
$this->assertEquals(config('app.url').'/storage/photo-small.jpg', $media->smallurl);
}
#[Test]
public function size_url_handles_dotted_basename(): void
{
$media = new Media;
$media->path = 'file.name.png';
$this->assertEquals(config('app.url').'/storage/file.name-medium.png', $media->mediumurl);
}
/**
* @dataProvider mimeTypeProvider
*/
#[DataProvider('mimeTypeProvider')]
public function mimetype_returns_correct_mime_for_extension(string $path, string $expected): void
{
$media = new Media;
$media->path = $path;
$this->assertEquals($expected, $media->mimetype);
}
public static function mimeTypeProvider(): array
{
return [
['photo.gif', 'image/gif'],
['photo.jpeg', 'image/jpeg'],
['photo.jpg', 'image/jpeg'],
['photo.png', 'image/png'],
['photo.svg', 'image/svg+xml'],
['photo.tiff', 'image/tiff'],
['photo.webp', 'image/webp'],
['video.mp4', 'video/mp4'],
['video.mkv', 'video/mkv'],
['file.bin', 'application/octet-stream'],
];
}
} }

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\Micropub\Data\UpdateData;
use Illuminate\Http\Request;
use JsonException;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class MicropubUpdateDataTest extends TestCase
{
/**
* @throws JsonException
*/
#[Test]
public function from_request_parses_json_body(): void
{
$request = Request::create('/micropub', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'token_data' => ['me' => 'https://example.com'],
'url' => 'https://example.com/notes/1',
'replace' => ['content' => ['new content']],
'add' => ['syndication' => ['https://twitter.com']],
'delete' => ['category' => ['old-tag']],
], JSON_THROW_ON_ERROR));
$dto = UpdateData::fromRequest($request);
$this->assertEquals(['me' => 'https://example.com'], $dto->tokenData);
$this->assertEquals('https://example.com/notes/1', $dto->updateUrl);
$this->assertEquals(['content' => ['new content']], $dto->updateReplace);
$this->assertEquals(['syndication' => ['https://twitter.com']], $dto->updateAdd);
$this->assertEquals(['category' => ['old-tag']], $dto->updateDelete);
}
#[Test]
public function from_request_parses_form_encoded_body(): void
{
$request = Request::create('/micropub', 'POST', [
'token_data' => ['me' => 'https://example.com'],
'url' => 'https://example.com/notes/1',
'replace' => ['content' => ['new content']],
'add' => ['syndication' => ['https://twitter.com']],
'delete' => ['category' => ['old-tag']],
]);
$dto = UpdateData::fromRequest($request);
$this->assertEquals(['me' => 'https://example.com'], $dto->tokenData);
$this->assertEquals('https://example.com/notes/1', $dto->updateUrl);
$this->assertEquals(['content' => ['new content']], $dto->updateReplace);
$this->assertEquals(['syndication' => ['https://twitter.com']], $dto->updateAdd);
$this->assertEquals(['category' => ['old-tag']], $dto->updateDelete);
}
/**
* @throws JsonException
*/
#[Test]
public function from_request_nullable_fields_default_to_null(): void
{
$request = Request::create('/micropub', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'token_data' => ['me' => 'https://example.com'],
'url' => 'https://example.com/notes/1',
], JSON_THROW_ON_ERROR));
$dto = UpdateData::fromRequest($request);
$this->assertNull($dto->updateReplace);
$this->assertNull($dto->updateAdd);
$this->assertNull($dto->updateDelete);
}
#[Test]
public function from_array_maps_all_fields(): void
{
$dto = UpdateData::fromArray([
'token_data' => ['me' => 'https://example.com'],
'update_url' => 'https://example.com/notes/1',
'update_replace' => ['content' => ['updated']],
'update_add' => ['category' => ['new-tag']],
'update_delete' => ['syndication' => ['https://old.example.com']],
]);
$this->assertEquals(['me' => 'https://example.com'], $dto->tokenData);
$this->assertEquals('https://example.com/notes/1', $dto->updateUrl);
$this->assertEquals(['content' => ['updated']], $dto->updateReplace);
$this->assertEquals(['category' => ['new-tag']], $dto->updateAdd);
$this->assertEquals(['syndication' => ['https://old.example.com']], $dto->updateDelete);
}
#[Test]
public function from_array_nullable_fields_default_to_null(): void
{
$dto = UpdateData::fromArray([
'token_data' => ['me' => 'https://example.com'],
]);
$this->assertNull($dto->updateUrl);
$this->assertNull($dto->updateReplace);
$this->assertNull($dto->updateAdd);
$this->assertNull($dto->updateDelete);
}
}

View file

@ -383,24 +383,6 @@ class NotesTest extends TestCase
); );
} }
/**
* For now, just reply on a cached object instead of actually querying Twitter.
*/
#[Test]
public function check_in_reply_to_is_twitter_link(): void
{
$tempContent = (object) [
'html' => 'something random',
];
Cache::put('933662564587855877', $tempContent);
$note = Note::factory()->create([
'in_reply_to' => 'https://twitter.com/someRando/status/933662564587855877',
]);
$this->assertEquals($tempContent, $note->twitter);
}
#[Test] #[Test]
public function latitude_and_longitude_can_be_parsed_from_plain_location(): void public function latitude_and_longitude_can_be_parsed_from_plain_location(): void
{ {

View file

@ -6,10 +6,8 @@ namespace Tests\Unit;
use App\Models\Note; use App\Models\Note;
use App\Models\WebMention; use App\Models\WebMention;
use Codebird\Codebird;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -82,41 +80,6 @@ class WebMentionTest extends TestCase
$this->assertEquals($expected, $webmention->createPhotoLink($twitterProfileImage)); $this->assertEquals($expected, $webmention->createPhotoLink($twitterProfileImage));
} }
#[Test]
public function create_photo_link_returns_cached_twitter_photo_links(): void
{
$webmention = new WebMention;
$twitterURL = 'https://twitter.com/example';
$expected = 'https://pbs.twimg.com/static_profile_link.jpg';
Cache::put($twitterURL, $expected, 1);
$this->assertEquals($expected, $webmention->createPhotoLink($twitterURL));
}
#[Test]
public function create_photo_link_resolves_twitter_photo_links(): void
{
$info = (object) [
'profile_image_url_https' => 'https://pbs.twimg.com/static_profile_link.jpg',
];
$codebirdMock = $this->createPartialMock(Codebird::class, ['__call']);
$codebirdMock->method('__call')
->with('users_show', $this->anything())
->willReturn($info);
$this->app->instance(Codebird::class, $codebirdMock);
Cache::shouldReceive('has')
->once()
->andReturn(false);
Cache::shouldReceive('put')
->once()
->andReturn(true);
$webmention = new WebMention;
$twitterURL = 'https://twitter.com/example';
$expected = 'https://pbs.twimg.com/static_profile_link.jpg';
$this->assertEquals($expected, $webmention->createPhotoLink($twitterURL));
}
#[Test] #[Test]
public function get_reply_attribute_defaults_to_null(): void public function get_reply_attribute_defaults_to_null(): void
{ {
@ -131,4 +94,37 @@ class WebMentionTest extends TestCase
$webmention->mf2 = json_encode(['no_html' => 'found_here']); $webmention->mf2 = json_encode(['no_html' => 'found_here']);
$this->assertNull($webmention->reply); $this->assertNull($webmention->reply);
} }
#[Test]
public function author_attribute_returns_null_when_mf2_is_null(): void
{
$webmention = new WebMention;
$webmention->mf2 = null;
$this->assertNull($webmention->author);
}
#[Test]
public function author_attribute_returns_hcard_without_photo_key_when_no_photo_present(): void
{
$webmention = new WebMention;
$webmention->mf2 = json_encode([
'items' => [[
'type' => ['h-entry'],
'properties' => [
'author' => [[
'type' => ['h-card'],
'properties' => [
'name' => ['Test Person'],
'url' => ['https://example.com'],
],
]],
'content' => ['hello'],
],
]],
]);
$hCard = $webmention->author;
$this->assertNotNull($hCard);
$this->assertArrayNotHasKey('photo', $hCard['properties'] ?? []);
}
} }