diff --git a/.env.example b/.env.example
index 4eb61db5..a1c38110 100644
--- a/.env.example
+++ b/.env.example
@@ -70,11 +70,6 @@ ADMIN_USER=admin# pick something better, this is used for `/admin`
ADMIN_PASS=password
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_QUEUE=false
@@ -83,8 +78,8 @@ SESSION_SAME_SITE=strict
LOG_SLACK_WEBHOOK_URL=
+BRRR_WEBHOOK_URL=
+
FLARE_KEY=
IGNITION_OPEN_AI_KEY=
-
-BRIDGY_MASTODON_TOKEN=
diff --git a/app/CommonMark/Renderers/MentionRenderer.php b/app/CommonMark/Renderers/MentionRenderer.php
index d970fac8..2803a822 100644
--- a/app/CommonMark/Renderers/MentionRenderer.php
+++ b/app/CommonMark/Renderers/MentionRenderer.php
@@ -28,10 +28,10 @@ class MentionRenderer implements NodeRendererInterface
// This is not [@]handle@instance, so return a Twitter link
if (count($parts) === 1) {
- return new HtmlElement('a', ['href' => 'https://twitter.com/' . $parts[0]], '@' . $mentionText);
+ return new HtmlElement('a', ['href' => 'https://twitter.com/'.$parts[0]], '@'.$mentionText);
}
// Render the Mastodon profile link
- return new HtmlElement('a', ['href' => 'https://' . $parts[1] . '/@' . $parts[0]], '@' . $mentionText);
+ return new HtmlElement('a', ['href' => 'https://'.$parts[1].'/@'.$parts[0]], '@'.$mentionText);
}
}
diff --git a/app/Console/Commands/CopyMediaToLocal.php b/app/Console/Commands/CopyMediaToLocal.php
index 2e8d2bce..90a0bd39 100644
--- a/app/Console/Commands/CopyMediaToLocal.php
+++ b/app/Console/Commands/CopyMediaToLocal.php
@@ -34,10 +34,10 @@ class CopyMediaToLocal extends Command
foreach ($media as $mediaItem) {
$filename = $mediaItem->path;
- $this->info('Processing: ' . $filename);
+ $this->info('Processing: '.$filename);
// If the file is already saved locally skip to next one
- if (Storage::disk('local')->exists('public/' . $filename)) {
+ if (Storage::disk('local')->exists('public/'.$filename)) {
$this->info('File already exists locally, skipping');
continue;
@@ -50,19 +50,19 @@ class CopyMediaToLocal extends Command
continue;
}
$contents = Storage::disk('s3')->get($filename);
- Storage::disk('local')->put('public/' . $filename, $contents);
+ Storage::disk('local')->put('public/'.$filename, $contents);
// Copy -medium and -small versions if they exist
$filenameParts = explode('.', $filename);
$extension = array_pop($filenameParts);
$basename = trim(implode('.', $filenameParts), '.');
- $mediumFilename = $basename . '-medium.' . $extension;
- $smallFilename = $basename . '-small.' . $extension;
+ $mediumFilename = $basename.'-medium.'.$extension;
+ $smallFilename = $basename.'-small.'.$extension;
if (Storage::disk('s3')->exists($mediumFilename)) {
- Storage::disk('local')->put('public/' . $mediumFilename, Storage::disk('s3')->get($mediumFilename));
+ Storage::disk('local')->put('public/'.$mediumFilename, Storage::disk('s3')->get($mediumFilename));
}
if (Storage::disk('s3')->exists($smallFilename)) {
- Storage::disk('local')->put('public/' . $smallFilename, Storage::disk('s3')->get($smallFilename));
+ Storage::disk('local')->put('public/'.$smallFilename, Storage::disk('s3')->get($smallFilename));
}
}
}
diff --git a/app/Console/Commands/MigrateMedia.php b/app/Console/Commands/MigrateMedia.php
new file mode 100644
index 00000000..9d81ed34
--- /dev/null
+++ b/app/Console/Commands/MigrateMedia.php
@@ -0,0 +1,101 @@
+hasTable('media_note')) {
+ $this->error('The table "media_note" does not exist.');
+
+ exit(1);
+ }
+
+ // Load all media already saved in `media_endpoint` table
+ $this->line('Updating existing local media');
+ $mediaEndpointMedia = Media::all();
+ // Save relationship in new `media_note` table based on `media_endpoint.note_id`
+ $this->withProgressBar($mediaEndpointMedia, function (Media $mediaEndpointMediaItem) {
+ $note = Note::find($mediaEndpointMediaItem->note_id);
+ if ($note) {
+ $note->media()->syncWithoutDetaching($mediaEndpointMediaItem->id);
+ }
+ });
+
+ // Load all media records from `media` table
+ $this->line('');
+ $this->line('Migrating old media from S3');
+ $oldMedia = DB::table('media')->get();
+ foreach ($oldMedia as $oldMediaItem) {
+ // We only want to process the S3 media
+ if ($oldMediaItem->disk !== 's3') {
+ $this->warn('Original media item never stored in S3');
+
+ continue;
+ }
+
+ // Check media exists in S3
+ if (! Storage::disk('s3')->exists($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) {
+ $this->warn('Original media item not found in S3');
+
+ continue;
+ }
+ // We want to just copy the file, check it does not already exist locally
+ if (Storage::disk('public')->exists('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) {
+ $this->warn('File already exists locally with filename of original media item');
+
+ continue;
+ }
+
+ // Save relationship based on `media.model_id`
+ // I have already checked they are all notes
+ $noteId = $oldMediaItem->model_id;
+ $note = Note::find($noteId);
+ if (! $note) {
+ $this->warn('Note no longer exists');
+
+ continue;
+ }
+
+ // Create media entry in database and attach to note
+ $newMediaItem = Media::create([
+ 'path' => 'media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name,
+ 'type' => 'image',
+ ]);
+ $note->media()->syncWithoutDetaching($newMediaItem->id);
+
+ // Copy the file
+ Storage::disk('public')->writeStream('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name, Storage::disk('s3')->readStream($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name));
+
+ $this->info('Media item migrated from S3');
+ }
+
+ $this->line('');
+ $this->line('Migration finished');
+ }
+}
diff --git a/app/Console/Commands/MigratePlaceDataFromPostgis.php b/app/Console/Commands/MigratePlaceDataFromPostgis.php
index 8d5d2c92..4e7c58c0 100644
--- a/app/Console/Commands/MigratePlaceDataFromPostgis.php
+++ b/app/Console/Commands/MigratePlaceDataFromPostgis.php
@@ -63,7 +63,7 @@ class MigratePlaceDataFromPostgis extends Command
$places = Place::all();
$places->each(function ($place) {
- $this->info('Extracting Postgis data for place: ' . $place->name);
+ $this->info('Extracting Postgis data for place: '.$place->name);
$place->latitude = $place->location->getLat();
$place->longitude = $place->location->getLng();
diff --git a/app/Console/Commands/ParseCachedWebMentions.php b/app/Console/Commands/ParseCachedWebMentions.php
index a6b29176..a27e2738 100644
--- a/app/Console/Commands/ParseCachedWebMentions.php
+++ b/app/Console/Commands/ParseCachedWebMentions.php
@@ -32,11 +32,11 @@ class ParseCachedWebMentions extends Command
*/
public function handle(FileSystem $filesystem): void
{
- $htmlFiles = $filesystem->allFiles(storage_path() . '/HTML');
+ $htmlFiles = $filesystem->allFiles(storage_path().'/HTML');
foreach ($htmlFiles as $file) {
if ($file->getExtension() !== 'backup') { // we don’t want to parse `.backup` files
$filepath = $file->getPathname();
- $this->info('Loading HTML from: ' . $filepath);
+ $this->info('Loading HTML from: '.$filepath);
$html = $filesystem->get($filepath);
$url = $this->urlFromFilename($filepath);
$webmention = WebMention::where('source', $url)->firstOrFail();
@@ -53,7 +53,7 @@ class ParseCachedWebMentions extends Command
*/
private function urlFromFilename(string $filepath): string
{
- $dir = mb_substr($filepath, mb_strlen(storage_path() . '/HTML/'));
+ $dir = mb_substr($filepath, mb_strlen(storage_path().'/HTML/'));
$url = str_replace(['http/', 'https/'], ['http://', 'https://'], $dir);
if (mb_substr($url, -10) === 'index.html') {
$url = mb_substr($url, 0, -10);
diff --git a/app/Console/Commands/ReDownloadWebMentions.php b/app/Console/Commands/ReDownloadWebMentions.php
index c6452ba9..c43a52bd 100644
--- a/app/Console/Commands/ReDownloadWebMentions.php
+++ b/app/Console/Commands/ReDownloadWebMentions.php
@@ -31,7 +31,7 @@ class ReDownloadWebMentions extends Command
{
$webmentions = WebMention::all();
foreach ($webmentions as $webmention) {
- $this->info('Initiation re-download of ' . $webmention->source);
+ $this->info('Initiation re-download of '.$webmention->source);
dispatch(new DownloadWebMention($webmention->source));
}
}
diff --git a/app/Console/Commands/ReprocessMediaImages.php b/app/Console/Commands/ReprocessMediaImages.php
new file mode 100644
index 00000000..b6c86c47
--- /dev/null
+++ b/app/Console/Commands/ReprocessMediaImages.php
@@ -0,0 +1,68 @@
+whereNotNull('image_widths')
+ ->where('image_widths', '>', 1000)
+ ->get();
+
+ $dryRun = $this->option('dry-run');
+
+ $this->info("Found {$media->count()} images to reprocess.".($dryRun ? ' (dry run)' : ''));
+
+ foreach ($media as $item) {
+ $path = $item->path;
+
+ if (! Storage::disk('public')->exists($path)) {
+ $this->warn("{$path}: original not found on public disk, skipping.");
+
+ continue;
+ }
+
+ if ($dryRun) {
+ $this->line("{$path} ({$item->image_widths}px wide)");
+
+ continue;
+ }
+
+ $this->info("Processing: {$path}");
+
+ $image = Image::fromStorage($path, 'public');
+ try {
+ $image->width();
+ } catch (ImageException) {
+ $this->warn(' Could not decode image, skipping.');
+
+ continue;
+ }
+
+ $filenameParts = explode('.', $path);
+ $extension = array_pop($filenameParts);
+ $basename = trim(implode('.', $filenameParts), '.');
+
+ Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes());
+ Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes());
+
+ $this->info(' Done.');
+ }
+
+ $this->info('Reprocessing complete.'.($dryRun ? ' (dry run — no files were changed)' : ''));
+ }
+}
diff --git a/app/Exceptions/MicropubUnsupportedModelException.php b/app/Exceptions/MicropubUnsupportedModelException.php
new file mode 100644
index 00000000..660f233f
--- /dev/null
+++ b/app/Exceptions/MicropubUnsupportedModelException.php
@@ -0,0 +1,7 @@
+ About::first()?->content,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Admin/AboutController.php b/app/Http/Controllers/Admin/AboutController.php
new file mode 100644
index 00000000..cc5be0ad
--- /dev/null
+++ b/app/Http/Controllers/Admin/AboutController.php
@@ -0,0 +1,32 @@
+ $about,
+ ]);
+ }
+
+ public function update(Request $request): RedirectResponse
+ {
+ $about = About::firstOrNew();
+ $about->content = $request->input('content');
+ $about->save();
+
+ return redirect()->route('admin.about.show');
+ }
+}
diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php
index eb45320c..211f9fa8 100644
--- a/app/Http/Controllers/Admin/ContactsController.php
+++ b/app/Http/Controllers/Admin/ContactsController.php
@@ -6,10 +6,11 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Contact;
-use GuzzleHttp\Client;
use Illuminate\Filesystem\Filesystem;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
class ContactsController extends Controller
@@ -75,7 +76,7 @@ class ContactsController extends Controller
if (request()->hasFile('avatar') && (request()->input('homepage') != '')) {
$dir = parse_url(request()->input('homepage'), PHP_URL_HOST);
- $destination = public_path() . '/assets/profile-images/' . $dir;
+ $destination = public_path().'/assets/profile-images/'.$dir;
$filesystem = new Filesystem;
if ($filesystem->isDirectory($destination) === false) {
$filesystem->makeDirectory($destination);
@@ -103,7 +104,7 @@ class ContactsController extends Controller
* This method attempts to find the microformat marked-up profile image
* from a given homepage and save it accordingly
*
- * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
+ * @return RedirectResponse|View
*/
public function getAvatar(int $contactId)
{
@@ -112,14 +113,13 @@ class ContactsController extends Controller
$avatar = null;
$contact = Contact::findOrFail($contactId);
if ($contact->homepage !== null && mb_strlen($contact->homepage) !== 0) {
- $client = resolve(Client::class);
try {
- $response = $client->get($contact->homepage);
- } catch (\GuzzleHttp\Exception\BadResponseException $e) {
- return redirect('/admin/contacts/' . $contactId . '/edit')
+ $response = Http::throw()->get($contact->homepage);
+ } catch (RequestException $e) {
+ return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Bad resposne from contact’s homepage');
}
- $mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage);
+ $mf2 = \Mf2\parse($response->body(), $contact->homepage);
foreach ($mf2['items'] as $microformat) {
if (Arr::get($microformat, 'type.0') === 'h-card') {
$avatarURL = Arr::get($microformat, 'properties.photo.0.value');
@@ -128,19 +128,19 @@ class ContactsController extends Controller
}
if ($avatarURL !== null) {
try {
- $avatar = $client->get($avatarURL);
- } catch (\GuzzleHttp\Exception\BadResponseException $e) {
- return redirect('/admin/contacts/' . $contactId . '/edit')
+ $avatar = Http::throw()->get($avatarURL);
+ } catch (RequestException $e) {
+ return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Unable to download avatar');
}
}
if ($avatar !== null) {
- $directory = public_path() . '/assets/profile-images/' . parse_url($contact->homepage, PHP_URL_HOST);
+ $directory = public_path().'/assets/profile-images/'.parse_url($contact->homepage, PHP_URL_HOST);
$filesystem = new Filesystem;
if ($filesystem->isDirectory($directory) === false) {
$filesystem->makeDirectory($directory);
}
- $filesystem->put($directory . '/image', $avatar->getBody());
+ $filesystem->put($directory.'/image', $avatar->body());
return view('admin.contacts.getavatarsuccess', [
'homepage' => parse_url($contact->homepage, PHP_URL_HOST),
@@ -148,6 +148,6 @@ class ContactsController extends Controller
}
}
- return redirect('/admin/contacts/' . $contactId . '/edit');
+ return redirect('/admin/contacts/'.$contactId.'/edit');
}
}
diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php
index 9f635f10..d9c18f5b 100644
--- a/app/Http/Controllers/Admin/PasskeysController.php
+++ b/app/Http/Controllers/Admin/PasskeysController.php
@@ -128,7 +128,7 @@ class PasskeysController extends Controller
// Unset session data to mitigate replay attacks
$request->session()->forget('create_options');
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;
@@ -145,7 +145,7 @@ class PasskeysController extends Controller
);
if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
- throw new WebAuthnException('Invalid response type');
+ throw new WebauthnException('Invalid response type');
}
$algorithmManager = new Manager;
@@ -254,7 +254,7 @@ class PasskeysController extends Controller
], 400);
}
- $passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id);
+ $passkey = Passkey::firstWhere('passkey_id', Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId));
if (! $passkey) {
return response()->json([
'success' => false,
diff --git a/app/Http/Controllers/Admin/PlacesController.php b/app/Http/Controllers/Admin/PlacesController.php
index e5e82bcd..26f47b79 100644
--- a/app/Http/Controllers/Admin/PlacesController.php
+++ b/app/Http/Controllers/Admin/PlacesController.php
@@ -120,6 +120,7 @@ class PlacesController extends Controller
foreach ($place1->notes as $note) {
$note->place()->dissociate();
$note->place()->associate($place2->id);
+ $note->save();
}
$place1->delete();
}
@@ -127,6 +128,7 @@ class PlacesController extends Controller
foreach ($place2->notes as $note) {
$note->place()->dissociate();
$note->place()->associate($place1->id);
+ $note->save();
}
$place2->delete();
}
diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php
new file mode 100644
index 00000000..99d283bb
--- /dev/null
+++ b/app/Http/Controllers/Admin/SettingsController.php
@@ -0,0 +1,32 @@
+ $settings,
+ ]);
+ }
+
+ public function update(Request $request): RedirectResponse
+ {
+ $settings = Setting::firstOrNew();
+ $settings->winter_effect_enabled = $request->boolean('winter_effect_enabled');
+ $settings->save();
+
+ return redirect()->route('admin.settings.show');
+ }
+}
diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php
new file mode 100644
index 00000000..02b9660c
--- /dev/null
+++ b/app/Http/Controllers/Admin/TokensController.php
@@ -0,0 +1,65 @@
+get();
+
+ return view('admin.tokens.index', compact('tokens'));
+ }
+
+ /**
+ * Show the form to manually generate a new Micropub token.
+ *
+ * This is for clients (e.g. iA Writer) that don't support the IndieAuth
+ * PKCE flow and instead expect to be given a token directly.
+ */
+ public function create(): View
+ {
+ return view('admin.tokens.create');
+ }
+
+ /**
+ * Manually generate a new Micropub token.
+ */
+ public function store(Request $request): RedirectResponse
+ {
+ $validated = $request->validate([
+ 'client_id' => 'required|string',
+ 'scope' => 'required|array|min:1',
+ ]);
+
+ $token = resolve(TokenService::class)->getNewToken([
+ 'me' => config('app.url'),
+ 'client_id' => $validated['client_id'],
+ 'scope' => implode(' ', $validated['scope']),
+ ]);
+
+ return redirect('/admin/tokens')->with('new_token', $token);
+ }
+
+ /**
+ * Revoke a Micropub token.
+ */
+ public function revoke(MicropubToken $token): RedirectResponse
+ {
+ $token->revoke();
+
+ return redirect('/admin/tokens');
+ }
+}
diff --git a/app/Http/Controllers/ArticlesController.php b/app/Http/Controllers/ArticlesController.php
index 9ab860d7..ff28fd50 100644
--- a/app/Http/Controllers/ArticlesController.php
+++ b/app/Http/Controllers/ArticlesController.php
@@ -38,9 +38,9 @@ class ArticlesController extends Controller
if ($article->updated_at->year != $year || $article->updated_at->month != $month) {
return redirect('/blog/'
- . $article->updated_at->year
- . '/' . $article->updated_at->format('m')
- . '/' . $slug);
+ .$article->updated_at->year
+ .'/'.$article->updated_at->format('m')
+ .'/'.$slug);
}
return view('articles.show', compact('article'));
diff --git a/app/Http/Controllers/ContactsController.php b/app/Http/Controllers/ContactsController.php
index 280cc3ed..13989eac 100644
--- a/app/Http/Controllers/ContactsController.php
+++ b/app/Http/Controllers/ContactsController.php
@@ -19,9 +19,9 @@ class ContactsController extends Controller
$contacts = Contact::all();
foreach ($contacts as $contact) {
$contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST);
- $file = public_path() . '/assets/profile-images/' . $contact->homepageHost . '/image';
+ $file = public_path().'/assets/profile-images/'.$contact->homepageHost.'/image';
$contact->image = ($filesystem->exists($file)) ?
- '/assets/profile-images/' . $contact->homepageHost . '/image'
+ '/assets/profile-images/'.$contact->homepageHost.'/image'
:
'/assets/profile-images/default-image';
}
@@ -35,11 +35,11 @@ class ContactsController extends Controller
public function show(Contact $contact): View
{
$contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST);
- $file = public_path() . '/assets/profile-images/' . $contact->homepageHost . '/image';
+ $file = public_path().'/assets/profile-images/'.$contact->homepageHost.'/image';
$filesystem = new Filesystem;
$image = ($filesystem->exists($file)) ?
- '/assets/profile-images/' . $contact->homepageHost . '/image'
+ '/assets/profile-images/'.$contact->homepageHost.'/image'
:
'/assets/profile-images/default-image';
diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php
index eb0847a3..9aeb4abb 100644
--- a/app/Http/Controllers/FeedsController.php
+++ b/app/Http/Controllers/FeedsController.php
@@ -7,73 +7,20 @@ namespace App\Http\Controllers;
use App\Models\Article;
use App\Models\Note;
use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Response;
class FeedsController extends Controller
{
- /**
- * Returns the blog RSS feed.
- */
- public function blogRss(): Response
- {
- $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
- $buildDate = $articles->first()->updated_at->toRssString();
-
- return response()
- ->view('articles.rss', compact('articles', 'buildDate'))
- ->header('Content-Type', 'application/rss+xml; charset=utf-8');
- }
-
- /**
- * Returns the blog Atom feed.
- */
- public function blogAtom(): Response
- {
- $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
-
- return response()
- ->view('articles.atom', compact('articles'))
- ->header('Content-Type', 'application/atom+xml; charset=utf-8');
- }
-
- /**
- * Returns the notes RSS feed.
- */
- public function notesRss(): Response
- {
- $notes = Note::latest()->take(20)->get();
- $buildDate = $notes->first()->updated_at->toRssString();
-
- return response()
- ->view('notes.rss', compact('notes', 'buildDate'))
- ->header('Content-Type', 'application/rss+xml; charset=utf-8');
- }
-
- /**
- * Returns the notes Atom feed.
- */
- public function notesAtom(): Response
- {
- $notes = Note::latest()->take(20)->get();
-
- return response()
- ->view('notes.atom', compact('notes'))
- ->header('Content-Type', 'application/atom+xml; charset=utf-8');
- }
-
- /** @todo sort out return type for json responses */
-
/**
* Returns the blog JSON feed.
*/
- public function blogJson(): array
+ public function blogJson(): JsonResponse
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$data = [
'version' => 'https://jsonfeed.org/version/1.1',
- 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s blog',
- 'home_page_url' => config('app.url') . '/blog',
- 'feed_url' => config('app.url') . '/blog/feed.json',
+ 'title' => 'The JSON Feed for '.config('user.display_name').'’s blog',
+ 'home_page_url' => config('app.url').'/blog',
+ 'feed_url' => config('app.url').'/blog/feed.json',
'authors' => [
[
'name' => config('user.display_name'),
@@ -85,29 +32,31 @@ class FeedsController extends Controller
foreach ($articles as $key => $article) {
$data['items'][$key] = [
- 'id' => config('app.url') . $article->link,
+ 'id' => config('app.url').$article->link,
'title' => $article->title,
- 'url' => config('app.url') . $article->link,
+ 'url' => config('app.url').$article->link,
'content_html' => $article->main,
'date_published' => $article->created_at->tz('UTC')->toRfc3339String(),
'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(),
];
}
- return $data;
+ return response()->json($data, 200, [
+ 'Content-Type' => 'application/feed+json',
+ ]);
}
/**
* Returns the notes JSON feed.
*/
- public function notesJson(): array
+ public function notesJson(): JsonResponse
{
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
$data = [
'version' => 'https://jsonfeed.org/version/1.1',
- 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s notes',
- 'home_page_url' => config('app.url') . '/notes',
- 'feed_url' => config('app.url') . '/notes/feed.json',
+ 'title' => 'The JSON Feed for '.config('user.display_name').'’s notes',
+ 'home_page_url' => config('app.url').'/notes',
+ 'feed_url' => config('app.url').'/notes/feed.json',
'authors' => [
[
'name' => config('user.display_name'),
@@ -130,7 +79,9 @@ class FeedsController extends Controller
}
}
- return $data;
+ return response()->json($data, 200, [
+ 'Content-Type' => 'application/feed+json',
+ ]);
}
/**
@@ -144,8 +95,8 @@ class FeedsController extends Controller
$items[] = [
'type' => 'entry',
'published' => $article->created_at,
- 'uid' => config('app.url') . $article->link,
- 'url' => config('app.url') . $article->link,
+ 'uid' => config('app.url').$article->link,
+ 'url' => config('app.url').$article->link,
'content' => [
'text' => $article->main,
'html' => $article->html,
@@ -156,7 +107,7 @@ class FeedsController extends Controller
return response()->json([
'type' => 'feed',
- 'name' => 'Blog feed for ' . config('app.name'),
+ 'name' => 'Blog feed for '.config('app.name'),
'url' => url('/blog'),
'author' => [
'type' => 'card',
@@ -192,7 +143,7 @@ class FeedsController extends Controller
return response()->json([
'type' => 'feed',
- 'name' => 'Notes feed for ' . config('app.name'),
+ 'name' => 'Notes feed for '.config('app.name'),
'url' => url('/notes'),
'author' => [
'type' => 'card',
diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php
index 45b488da..a795bce8 100644
--- a/app/Http/Controllers/IndieAuthController.php
+++ b/app/Http/Controllers/IndieAuthController.php
@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Controllers;
+use App\Models\MicropubToken;
use App\Services\TokenService;
-use Exception;
-use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View;
use Random\RandomException;
@@ -25,9 +25,10 @@ class IndieAuthController extends Controller
'issuer' => config('app.url'),
'authorization_endpoint' => route('indieauth.start'),
'token_endpoint' => route('indieauth.token'),
+ 'revocation_endpoint' => route('indieauth.revocation'),
+ 'introspection_endpoint' => route('indieauth.introspection'),
+ 'introspection_endpoint_auth_methods_supported' => ['Bearer'],
'code_challenge_methods_supported' => ['S256'],
- // 'introspection_endpoint' => route('indieauth.introspection'),
- // 'introspection_endpoint_auth_methods_supported' => ['none'],
]);
}
@@ -179,6 +180,50 @@ class IndieAuthController extends Controller
]);
}
+ /**
+ * Process a POST request to the IndieAuth revocation endpoint (RFC 7009).
+ *
+ * Per spec this always returns HTTP 200, whether the token was revoked,
+ * unknown, or already revoked, so callers can't probe token validity.
+ */
+ public function processRevocationRequest(Request $request): JsonResponse
+ {
+ MicropubToken::findActive($request->get('token'))?->revoke();
+
+ return response()->json([], 200);
+ }
+
+ /**
+ * Process a POST request to the IndieAuth token introspection endpoint
+ * (RFC 7662, extended by IndieAuth to require the `me` property).
+ *
+ * The caller must itself present a currently-active token as a Bearer
+ * credential to use this endpoint, per spec ("MUST also require some
+ * form of authorization"). Per spec, an inactive token being introspected
+ * still gets a 200 response containing only `active: false` - no other
+ * information about why it's inactive is given.
+ */
+ public function processIntrospectionRequest(Request $request): JsonResponse
+ {
+ if (! MicropubToken::findActive($request->bearerToken())) {
+ return response()->json([], 401);
+ }
+
+ $token = MicropubToken::findActive($request->get('token'));
+
+ if (! $token) {
+ return response()->json(['active' => false]);
+ }
+
+ return response()->json([
+ 'active' => true,
+ 'me' => $token->me,
+ 'client_id' => $token->client_id,
+ 'scope' => $token->scope,
+ 'iat' => $token->created_at->timestamp,
+ ]);
+ }
+
protected function isValidRedirectUri(string $clientId, string $redirectUri): bool
{
// If client_id is not a valid URL, then it's not valid
@@ -199,15 +244,13 @@ class IndieAuthController extends Controller
}
// Otherwise we need to check the redirect_uri is in the client_id's redirect_uris
- $guzzle = resolve(Client::class);
-
try {
- $clientInfo = $guzzle->get($clientId);
- } catch (Exception) {
+ $clientInfo = Http::throw()->get($clientId);
+ } catch (\Throwable) {
return false;
}
- $clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId);
+ $clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId);
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];
diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php
index 758b3255..72242150 100644
--- a/app/Http/Controllers/MicropubController.php
+++ b/app/Http/Controllers/MicropubController.php
@@ -6,13 +6,15 @@ namespace App\Http\Controllers;
use App\Exceptions\InvalidTokenScopeException;
use App\Exceptions\MicropubHandlerException;
+use App\Exceptions\MicropubUnsupportedModelException;
use App\Http\Requests\MicropubRequest;
use App\Models\Place;
use App\Models\SyndicationTarget;
+use App\Services\Micropub\Data\MicropubData;
use App\Services\Micropub\MicropubHandlerRegistry;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-use Lcobucci\JWT\Token;
class MicropubController extends Controller
{
@@ -26,9 +28,9 @@ class MicropubController extends Controller
/**
* Respond to a POST request to the micropub endpoint.
*
- * The request is initially processed by the MicropubRequest form request
- * class. The normalizes the data, so we can pass it into the handlers for
- * the different micropub requests, h-entry or h-card, for example.
+ * MicropubRequest detects the request type (e.g. entry, card, update).
+ * The handler registry resolves the appropriate handler, whose DTO
+ * extracts the relevant fields from the request via fromRequest().
*/
public function post(MicropubRequest $request): JsonResponse
{
@@ -43,29 +45,53 @@ class MicropubController extends Controller
try {
$handler = $this->handlerRegistry->getHandler($type);
- $result = $handler->handle($request->getMicropubData());
+ $dataClass = $handler->dataClass();
+ /** @var MicropubData $data */
+ $data = $dataClass::fromRequest($request);
+ $result = $handler->handle($data);
+
+ if ($result['response'] === 'updated') {
+ return response()->json([
+ 'response' => $result['response'],
+ ], 200)->header('Location', $result['url']);
+ }
- // Return appropriate response based on the handler result
return response()->json([
'response' => $result['response'],
'location' => $result['url'] ?? null,
], 201)->header('Location', $result['url']);
+ } catch (InvalidTokenScopeException) {
+ return response()->json([
+ 'error' => 'insufficient_scope',
+ 'error_description' => 'The token does not have the required scope for this request',
+ ], 401);
+ } catch (ModelNotFoundException) {
+ return response()->json([
+ 'error' => 'invalid_request',
+ 'error_description' => 'No known note with given ID',
+ ], 404);
+ } catch (MicropubUnsupportedModelException $e) {
+ report($e);
+
+ return response()->json([
+ 'error' => 'invalid',
+ 'error_description' => 'This implementation currently only supports the updating of notes',
+ ], 500);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'invalid_request',
'error_description' => $e->getMessage(),
], 400);
- } catch (MicropubHandlerException) {
+ } catch (MicropubHandlerException $e) {
+ report($e);
+
return response()->json([
- 'error' => 'Unknown Micropub type',
+ 'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server',
], 500);
- } catch (InvalidTokenScopeException) {
- return response()->json([
- 'error' => 'invalid_scope',
- 'error_description' => 'The token does not have the required scope for this request',
- ], 403);
- } catch (\Exception) {
+ } catch (\Throwable $e) {
+ report($e);
+
return response()->json([
'error' => 'server_error',
'error_description' => 'An error occurred processing the request',
@@ -76,10 +102,9 @@ class MicropubController extends Controller
/**
* Respond to a GET request to the micropub endpoint.
*
- * A GET request has been made to `api/post` with an accompanying
- * token, here we check whether the token is valid and respond
- * appropriately. Further if the request has the query parameter
- * syndicate-to we respond with the known syndication endpoints.
+ * Token validation is handled by the VerifyMicropubToken middleware.
+ * Supports q=syndicate-to, q=config, and q=geo:, queries.
+ * The default response returns the token metadata.
*/
public function get(Request $request): JsonResponse
{
@@ -115,7 +140,7 @@ class MicropubController extends Controller
}
// the default response is just to return the token data
- /** @var Token $tokenData */
+ /** @var array $tokenData */
$tokenData = $request->input('token_data');
return response()->json([
diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php
index fc804ea2..d9f8ea32 100644
--- a/app/Http/Controllers/MicropubMediaController.php
+++ b/app/Http/Controllers/MicropubMediaController.php
@@ -13,9 +13,10 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
+use Illuminate\Image\ImageException;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
-use Intervention\Image\ImageManager;
use Ramsey\Uuid\Uuid;
class MicropubMediaController extends Controller
@@ -25,9 +26,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data');
$scopes = $tokenData['scope'];
- if (is_string($scopes)) {
- $scopes = explode(' ', $scopes);
- }
+ $scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}
@@ -83,9 +82,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data');
$scopes = $tokenData['scope'];
- if (is_string($scopes)) {
- $scopes = explode(' ', $scopes);
- }
+ $scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}
@@ -111,12 +108,9 @@ class MicropubMediaController extends Controller
$filename = Storage::disk('local')->putFile('media', $file);
- /** @var ImageManager $manager */
- $manager = resolve(ImageManager::class);
try {
- $image = $manager->read($request->file('file'));
- $width = $image->width();
- } catch (Exception) {
+ $width = Image::fromUpload($request->file('file'))->width();
+ } catch (ImageException) {
// not an image
$width = null;
}
@@ -193,7 +187,7 @@ class MicropubMediaController extends Controller
*/
private function saveFileToLocal(UploadedFile $file): string
{
- $filename = Uuid::uuid4()->toString() . '.' . $file->extension();
+ $filename = Uuid::uuid4()->toString().'.'.$file->extension();
Storage::disk('local')->putFileAs('', $file, $filename);
return $filename;
diff --git a/app/Http/Controllers/NotesController.php b/app/Http/Controllers/NotesController.php
index d5c9bc90..ab81002b 100644
--- a/app/Http/Controllers/NotesController.php
+++ b/app/Http/Controllers/NotesController.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Note;
-use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
@@ -53,7 +52,7 @@ class NotesController extends Controller
->withCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}])->firstOrFail();
- } catch (ModelNotFoundException $exception) {
+ } catch (\Exception) {
abort(404);
}
@@ -65,7 +64,7 @@ class NotesController extends Controller
*/
public function redirect(int $decId): RedirectResponse
{
- return redirect(config('app.url') . '/notes/' . (new Numbers)->numto60($decId));
+ return redirect(config('app.url').'/notes/'.(new Numbers)->numto60($decId));
}
/**
diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php
index 467283db..e2810f87 100644
--- a/app/Http/Middleware/LinkHeadersMiddleware.php
+++ b/app/Http/Middleware/LinkHeadersMiddleware.php
@@ -14,11 +14,13 @@ class LinkHeadersMiddleware
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
- $response->header('Link', '<' . route('indieauth.metadata') . '>; rel="indieauth-metadata"', false);
- $response->header('Link', '<' . route('indieauth.start') . '>; rel="authorization_endpoint"', false);
- $response->header('Link', '<' . route('indieauth.token') . '>; rel="token_endpoint"', false);
- $response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false);
- $response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false);
+ $response->header('Link', '<'.route('indieauth.metadata').'>; rel="indieauth-metadata"', false);
+ $response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', false);
+ $response->header('Link', '<'.route('indieauth.token').'>; rel="token_endpoint"', false);
+ $response->header('Link', '<'.route('indieauth.revocation').'>; rel="revocation_endpoint"', false);
+ $response->header('Link', '<'.route('indieauth.introspection').'>; rel="introspection_endpoint"', false);
+ $response->header('Link', '<'.route('micropub-endpoint').'>; rel="micropub"', false);
+ $response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false);
return $response;
}
diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php
index a6a6c8c4..1e06ab05 100644
--- a/app/Http/Middleware/RedirectIfAuthenticated.php
+++ b/app/Http/Middleware/RedirectIfAuthenticated.php
@@ -16,7 +16,7 @@ class RedirectIfAuthenticated
/**
* 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
{
diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php
index 33d2cb12..530995ae 100644
--- a/app/Http/Middleware/VerifyMicropubToken.php
+++ b/app/Http/Middleware/VerifyMicropubToken.php
@@ -5,13 +5,9 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
+use App\Models\MicropubToken;
use Closure;
use Illuminate\Http\Request;
-use Lcobucci\JWT\Configuration;
-use Lcobucci\JWT\Encoding\CannotDecodeContent;
-use Lcobucci\JWT\Token;
-use Lcobucci\JWT\Token\InvalidTokenStructure;
-use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken
@@ -39,15 +35,15 @@ class VerifyMicropubToken
], 401);
}
- try {
- $tokenData = $this->validateToken($rawToken);
- } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
+ $token = MicropubToken::findActive($rawToken);
+
+ if (! $token) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
- if ($tokenData->claims()->has('scope') === false) {
+ if ($token->scope === '') {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
@@ -56,26 +52,10 @@ class VerifyMicropubToken
return $next($request->merge([
'access_token' => $rawToken,
'token_data' => [
- 'me' => $tokenData->claims()->get('me'),
- 'scope' => $tokenData->claims()->get('scope'),
- 'client_id' => $tokenData->claims()->get('client_id'),
+ 'me' => $token->me,
+ 'scope' => $token->scope,
+ 'client_id' => $token->client_id,
],
]));
}
-
- /**
- * Check the token signature is valid.
- */
- private function validateToken(string $bearerToken): Token
- {
- $config = resolve(Configuration::class);
-
- $token = $config->parser()->parse($bearerToken);
-
- $constraints = $config->validationConstraints();
-
- $config->validator()->assert($token, ...$constraints);
-
- return $token;
- }
}
diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php
index 41c70280..ab4fde74 100644
--- a/app/Http/Requests/MicropubRequest.php
+++ b/app/Http/Requests/MicropubRequest.php
@@ -5,12 +5,9 @@ declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
-use Illuminate\Support\Arr;
class MicropubRequest extends FormRequest
{
- protected array $micropubData = [];
-
public function rules(): array
{
return [
@@ -18,102 +15,25 @@ class MicropubRequest extends FormRequest
];
}
- public function getMicropubData(): array
- {
- return $this->micropubData;
- }
-
public function getType(): ?string
{
- // Return consistent type regardless of input format
- return $this->micropubData['type'] ?? null;
- }
-
- protected function prepareForValidation(): void
- {
- // Normalize the request data based on content type
if ($this->isJson()) {
- $this->normalizeMicropubJson();
- } else {
- $this->normalizeMicropubForm();
- }
- }
+ $data = $this->json()->all();
- private function normalizeMicropubJson(): void
- {
- $json = $this->json();
- if ($json === null) {
- throw new \InvalidArgumentException('`isJson()` passed but there is no json data');
- }
-
- $data = $json->all();
-
- // Convert JSON type (h-entry) to simple type (entry)
- if (isset($data['type']) && is_array($data['type'])) {
- $type = current($data['type']);
- if (str_starts_with($type, 'h-')) {
- $this->micropubData['type'] = substr($type, 2);
+ if (isset($data['action']) && $data['action'] === 'update') {
+ return 'update';
}
- }
- // Or set the type to update
- elseif (isset($data['action']) && $data['action'] === 'update') {
- $this->micropubData['type'] = 'update';
- }
- // Add in the token data
- $this->micropubData['token_data'] = $data['token_data'];
+ if (isset($data['type']) && is_array($data['type'])) {
+ $type = current($data['type']);
+ if (str_starts_with($type, 'h-')) {
+ return substr($type, 2);
+ }
+ }
- // Add h-entry values
- $this->micropubData['content'] = Arr::get($data, 'properties.content.0');
- $this->micropubData['in-reply-to'] = Arr::get($data, 'properties.in-reply-to.0');
- $this->micropubData['published'] = Arr::get($data, 'properties.published.0');
- $this->micropubData['location'] = $this->getLocationData($data);
- $this->micropubData['bookmark-of'] = Arr::get($data, 'properties.bookmark-of.0');
- $this->micropubData['like-of'] = Arr::get($data, 'properties.like-of.0');
- $this->micropubData['mp-syndicate-to'] = Arr::get($data, 'properties.mp-syndicate-to');
-
- // Add h-card values
- $this->micropubData['name'] = Arr::get($data, 'properties.name.0');
- $this->micropubData['description'] = Arr::get($data, 'properties.description.0');
- $this->micropubData['geo'] = Arr::get($data, 'properties.geo.0');
-
- // Add checkin value
- $this->micropubData['checkin'] = Arr::get($data, 'checkin');
- $this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0');
- }
-
- private function normalizeMicropubForm(): void
- {
- // Convert form h=entry to type=entry
- if ($h = $this->input('h')) {
- $this->micropubData['type'] = $h;
- }
-
- // Add some fields to the micropub data with default null values
- $this->micropubData['in-reply-to'] = null;
- $this->micropubData['published'] = null;
- $this->micropubData['location'] = null;
- $this->micropubData['description'] = null;
- $this->micropubData['geo'] = null;
- $this->micropubData['latitude'] = null;
- $this->micropubData['longitude'] = null;
-
- // Map form fields to micropub data
- foreach ($this->except(['h', 'access_token']) as $key => $value) {
- $this->micropubData[$key] = $value;
- }
- }
-
- private function getLocationData(array $data): array|string|null
- {
- if (! Arr::has($data, 'properties.location')) {
return null;
}
- if (Arr::has($data, 'properties.location.0')) {
- return Arr::get($data, 'properties.location.0');
- }
-
- return Arr::get($data, 'properties.location');
+ return $this->input('h') ?: null;
}
}
diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php
index 3c187dd4..0cb073d5 100644
--- a/app/Jobs/DownloadWebMention.php
+++ b/app/Jobs/DownloadWebMention.php
@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Jobs;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\FileSystem\FileSystem;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
class DownloadWebMention implements ShouldQueue
{
@@ -29,19 +29,19 @@ class DownloadWebMention implements ShouldQueue
/**
* Execute the job.
*
- * @throws GuzzleException
+ * @throws RequestException
* @throws FileNotFoundException
*/
- public function handle(Client $guzzle): void
+ public function handle(): void
{
- $response = $guzzle->request('GET', $this->source);
- // 4XX and 5XX responses should get Guzzle to throw an exception,
- // Laravel should catch and retry these automatically.
- if ($response->getStatusCode() === 200) {
+ // 4XX and 5XX responses should throw so Laravel can catch and
+ // retry these automatically.
+ $response = Http::throw()->get($this->source);
+ if ($response->status() === 200) {
$filesystem = new FileSystem;
- $filename = storage_path('HTML') . '/' . $this->createFilenameFromURL($this->source);
+ $filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source);
// backup file first
- $filenameBackup = $filename . '.' . date('Y-m-d') . '.backup';
+ $filenameBackup = $filename.'.'.date('Y-m-d').'.backup';
if ($filesystem->exists($filename)) {
$filesystem->copy($filename, $filenameBackup);
}
@@ -56,7 +56,7 @@ class DownloadWebMention implements ShouldQueue
// save new HTML
$filesystem->put(
$filename,
- (string) $response->getBody()
+ $response->body()
);
// remove backup if the same
if ($filesystem->exists($filenameBackup)) {
diff --git a/app/Jobs/NotifyBrrrOfWebMention.php b/app/Jobs/NotifyBrrrOfWebMention.php
new file mode 100644
index 00000000..3273b7d6
--- /dev/null
+++ b/app/Jobs/NotifyBrrrOfWebMention.php
@@ -0,0 +1,56 @@
+ $this->title(),
+ 'message' => "From {$this->webMention->source}",
+ 'open_url' => $this->webMention->target,
+ ]);
+ }
+
+ /**
+ * Build a notification title based on the webmention type.
+ */
+ private function title(): string
+ {
+ return match ($this->webMention->type) {
+ 'in-reply-to' => 'New reply',
+ 'like-of' => 'New like',
+ 'repost-of' => 'New repost',
+ default => 'New webmention',
+ };
+ }
+}
diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php
index 3c6028a9..3ed065c1 100644
--- a/app/Jobs/ProcessLike.php
+++ b/app/Jobs/ProcessLike.php
@@ -5,16 +5,13 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Like;
-use Codebird\Codebird;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\GuzzleException;
-use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
@@ -34,42 +31,11 @@ class ProcessLike implements ShouldQueue
/**
* Execute the job.
- *
- * @throws GuzzleException
*/
- public function handle(Client $client, Authorship $authorship): int
+ public function handle(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);
- $mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url);
+ $response = Http::throw()->get($this->like->url);
+ $mf2 = \Mf2\parse($response->body(), $this->like->url);
if (Arr::has($mf2, 'items.0.properties.content')) {
$this->like->content = $mf2['items'][0]['properties']['content'][0]['html'];
}
@@ -91,15 +57,4 @@ class ProcessLike implements ShouldQueue
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';
- }
}
diff --git a/app/Jobs/ProcessMedia.php b/app/Jobs/ProcessMedia.php
index b7f36648..78aeba3e 100644
--- a/app/Jobs/ProcessMedia.php
+++ b/app/Jobs/ProcessMedia.php
@@ -7,11 +7,11 @@ namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Image\ImageException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
-use Intervention\Image\Exceptions\DecoderException;
-use Intervention\Image\ImageManager;
class ProcessMedia implements ShouldQueue
{
@@ -30,40 +30,38 @@ class ProcessMedia implements ShouldQueue
/**
* Execute the job.
*/
- public function handle(ImageManager $manager): void
+ public function handle(): void
{
// Load file
- $file = Storage::disk('local')->get('media/' . $this->filename);
+ $file = Storage::disk('local')->get($this->filename);
// Open file
+ $image = Image::fromStorage($this->filename, 'local');
try {
- $image = $manager->read($file);
- } catch (DecoderException) {
+ $width = $image->width();
+ } catch (ImageException) {
// not an image; delete file and end job
- Storage::disk('local')->delete('media/' . $this->filename);
+ Storage::disk('local')->delete($this->filename);
return;
}
// Save the file publicly
- Storage::disk('public')->put('media/' . $this->filename, $file);
+ Storage::disk('public')->put($this->filename, $file);
// Create smaller versions if necessary
- if ($image->width() > 1000) {
+ if ($width > 1000) {
$filenameParts = explode('.', $this->filename);
$extension = array_pop($filenameParts);
// the following achieves this data flow
// foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
$basename = trim(implode('.', $filenameParts), '.');
- $medium = $image->resize(width: 1000);
- Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode());
-
- $small = $image->resize(width: 500);
- Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode());
+ Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes());
+ Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes());
}
// Now we can delete the locally saved image
- Storage::disk('local')->delete('media/' . $this->filename);
+ Storage::disk('local')->delete($this->filename);
}
}
diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php
index d92dfa18..4ac6f5fd 100644
--- a/app/Jobs/ProcessWebMention.php
+++ b/app/Jobs/ProcessWebMention.php
@@ -7,13 +7,12 @@ namespace App\Jobs;
use App\Exceptions\RemoteContentNotFoundException;
use App\Models\Note;
use App\Models\WebMention;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\GuzzleException;
-use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException;
use Jonnybarnes\WebmentionsParser\Parser;
use Mf2;
@@ -36,18 +35,20 @@ class ProcessWebMention implements ShouldQueue
* Execute the job.
*
* @throws RemoteContentNotFoundException
- * @throws GuzzleException
* @throws InvalidMentionException
*/
- public function handle(Parser $parser, Client $guzzle): void
+ public function handle(Parser $parser): void
{
try {
- $response = $guzzle->request('GET', $this->source);
- } catch (RequestException $e) {
+ $response = Http::get($this->source);
+ } catch (ConnectionException) {
throw new RemoteContentNotFoundException;
}
- $this->saveRemoteContent((string) $response->getBody(), $this->source);
- $microformats = Mf2\parse((string) $response->getBody(), $this->source);
+ if ($response->failed()) {
+ throw new RemoteContentNotFoundException;
+ }
+ $this->saveRemoteContent($response->body(), $this->source);
+ $microformats = Mf2\parse($response->body(), $this->source);
$webmentions = WebMention::where('source', $this->source)->get();
foreach ($webmentions as $webmention) {
// check webmention still references target
@@ -95,6 +96,7 @@ class ProcessWebMention implements ShouldQueue
$webmention->type = $type;
$webmention->mf2 = json_encode($microformats);
$webmention->save();
+ dispatch(new NotifyBrrrOfWebMention($webmention));
}
/**
@@ -110,7 +112,7 @@ class ProcessWebMention implements ShouldQueue
if (str_ends_with($url, '/')) {
$filenameFromURL .= 'index.html';
}
- $path = storage_path() . '/HTML/' . $filenameFromURL;
+ $path = storage_path().'/HTML/'.$filenameFromURL;
$parts = explode('/', $path);
$name = array_pop($parts);
$dir = implode('/', $parts);
diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php
index 08152d5b..aa7d8af7 100644
--- a/app/Jobs/SaveProfileImage.php
+++ b/app/Jobs/SaveProfileImage.php
@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace App\Jobs;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
@@ -55,20 +56,18 @@ class SaveProfileImage implements ShouldQueue
&& parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com'
&& parse_url($photo, PHP_URL_HOST) !== 'twitter.com'
) {
- $client = resolve(Client::class);
-
try {
- $response = $client->get($photo);
- $image = $response->getBody();
- } catch (RequestException) {
+ $response = Http::throw()->get($photo);
+ $image = $response->body();
+ } catch (ConnectionException|RequestException) {
// we are opening and reading the default image so that
- $default = public_path() . '/assets/profile-images/default-image';
+ $default = public_path().'/assets/profile-images/default-image';
$handle = fopen($default, 'rb');
$image = fread($handle, filesize($default));
fclose($handle);
}
- $path = public_path() . '/assets/profile-images/' . parse_url($home, PHP_URL_HOST) . '/image';
+ $path = public_path().'/assets/profile-images/'.parse_url($home, PHP_URL_HOST).'/image';
$parts = explode('/', $path);
$name = array_pop($parts);
$dir = implode('/', $parts);
diff --git a/app/Jobs/SaveScreenshot.php b/app/Jobs/SaveScreenshot.php
index 0e07efbd..b72da7b0 100755
--- a/app/Jobs/SaveScreenshot.php
+++ b/app/Jobs/SaveScreenshot.php
@@ -5,14 +5,15 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Bookmark;
-use GuzzleHttp\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Http\Client\PendingRequest;
+use Illuminate\Http\Client\Response;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
-use JsonException;
class SaveScreenshot implements ShouldQueue
{
@@ -27,77 +28,68 @@ class SaveScreenshot implements ShouldQueue
/**
* Execute the job.
- *
- *
- * @throws JsonException
*/
public function handle(): void
{
- // A normal Guzzle client
- $client = resolve(Client::class);
- // A Guzzle client with a custom Middleware to retry the CloudConvert API requests
- $retryClient = resolve('RetryGuzzle');
+ $cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2')
+ ->withToken(config('services.cloudconvert.token'))
+ ->throw();
// First request that CloudConvert takes a screenshot of the URL
- $takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [
- 'headers' => [
- 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
- ],
- 'json' => [
- 'url' => $this->bookmark->url,
- 'output_format' => 'png',
- 'screen_width' => 1440,
- 'screen_height' => 900,
- 'wait_until' => 'networkidle0',
- 'wait_time' => 100,
- ],
+ $takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [
+ 'url' => $this->bookmark->url,
+ 'output_format' => 'png',
+ 'screen_width' => 1440,
+ 'screen_height' => 900,
+ 'wait_until' => 'networkidle0',
+ 'wait_time' => 100,
]);
- $taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
+ $taskId = $takeScreenshotJobResponse->json('data.id');
// Now wait till the status job is finished
- $screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $taskId, [
- 'headers' => [
- 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
- ],
- 'query' => [
- 'include' => 'payload',
- ],
- ]);
+ $screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId);
- $finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
+ $finishedCaptureId = $screenshotJobStatusResponse->json('data.id');
// Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from
- $exportImageJob = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [
- 'headers' => [
- 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
- ],
- 'json' => [
- 'input' => $finishedCaptureId,
- 'archive_multiple_files' => false,
- ],
+ $exportImageJob = $cloudConvert->post('/export/url', [
+ 'input' => $finishedCaptureId,
+ 'archive_multiple_files' => false,
]);
- $exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
+ $exportImageJobId = $exportImageJob->json('data.id');
// Again, wait till the status of this export job is finished
- $finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $exportImageJobId, [
- 'headers' => [
- 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
- ],
- 'query' => [
- 'include' => 'payload',
- ],
- ]);
+ $finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId);
// Now we can download the screenshot and save it to the storage
- $finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url;
+ $finalImageUrl = $finalImageUrlResponse->json('data.result.files.0.url');
- $finalImageUrlContent = $client->request('GET', $finalImageUrl);
+ $finalImageUrlContent = Http::throw()->get($finalImageUrl);
- Storage::disk('public')->put('/assets/img/bookmarks/' . $taskId . '.png', $finalImageUrlContent->getBody()->getContents());
+ Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->body());
$this->bookmark->screenshot = $taskId;
$this->bookmark->save();
}
+
+ /**
+ * Poll a CloudConvert task until it reports a "finished" status.
+ */
+ private function pollUntilFinished(PendingRequest $client, string $taskId): Response
+ {
+ $attempts = 0;
+
+ do {
+ $response = $client->get('/tasks/'.$taskId, ['include' => 'payload']);
+ $finished = $response->json('data.status') === 'finished';
+ if (! $finished) {
+ $attempts++;
+ usleep(1_000_000); // 1 second, matches CloudConvert's own polling guidance
+ }
+ } while (! $finished && $attempts < 5);
+
+ return $response;
+ }
}
diff --git a/app/Jobs/SendWebMentions.php b/app/Jobs/SendWebMentions.php
index 2ff5f2c6..d8e962e3 100644
--- a/app/Jobs/SendWebMentions.php
+++ b/app/Jobs/SendWebMentions.php
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Header;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils;
@@ -14,7 +12,9 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
+use Mf2\Parser;
class SendWebMentions implements ShouldQueue
{
@@ -31,8 +31,6 @@ class SendWebMentions implements ShouldQueue
/**
* Execute the job.
- *
- * @throws GuzzleException
*/
public function handle(): void
{
@@ -42,12 +40,9 @@ class SendWebMentions implements ShouldQueue
foreach ($urls as $url) {
$endpoint = $this->discoverWebmentionEndpoint($url);
if ($endpoint !== null) {
- $guzzle = resolve(Client::class);
- $guzzle->post($endpoint, [
- 'form_params' => [
- 'source' => $this->note->uri,
- 'target' => $url,
- ],
+ Http::asForm()->post($endpoint, [
+ 'source' => $this->note->uri,
+ 'target' => $url,
]);
}
}
@@ -55,8 +50,6 @@ class SendWebMentions implements ShouldQueue
/**
* Discover if a URL has a webmention endpoint.
- *
- * @throws GuzzleException
*/
public function discoverWebmentionEndpoint(string $url): ?string
{
@@ -70,10 +63,9 @@ class SendWebMentions implements ShouldQueue
$endpoint = null;
- $guzzle = resolve(Client::class);
- $response = $guzzle->get($url);
+ $response = Http::get($url);
// check HTTP Headers for webmention endpoint
- $links = Header::parse($response->getHeader('Link'));
+ $links = Header::parse($response->header('Link'));
foreach ($links as $link) {
if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) {
return $this->resolveUri(trim($link[0], '<>'), $url);
@@ -81,9 +73,13 @@ class SendWebMentions implements ShouldQueue
}
// failed to find a header so parse HTML
- $html = (string) $response->getBody();
+ $html = $response->body();
- $mf2 = new \Mf2\Parser($html, $url);
+ if ($html === '') {
+ return null;
+ }
+
+ $mf2 = new Parser($html, $url);
$rels = $mf2->parseRelsAndAlternates();
if (array_key_exists('webmention', $rels[0])) {
$endpoint = $rels[0]['webmention'][0];
diff --git a/app/Jobs/SyndicateNoteToBluesky.php b/app/Jobs/SyndicateNoteToBluesky.php
index e815be34..582ef760 100644
--- a/app/Jobs/SyndicateNoteToBluesky.php
+++ b/app/Jobs/SyndicateNoteToBluesky.php
@@ -5,18 +5,22 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
class SyndicateNoteToBluesky implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ /**
+ * Only try once — retrying would send Bridgy a duplicate publish webmention.
+ */
+ public int $tries = 1;
+
/**
* Create a new job instance.
*/
@@ -26,37 +30,26 @@ class SyndicateNoteToBluesky implements ShouldQueue
/**
* Execute the job.
- *
- * @throws GuzzleException
*/
- public function handle(Client $guzzle): void
+ public function handle(): void
{
- // We can only make the request if we have an access token
- if (config('bridgy.bluesky_token') === null) {
+ // no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
+ $response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
+ 'source' => $this->note->uri,
+ 'target' => 'https://brid.gy/publish/bluesky',
+ ]);
+
+ $body = $response->json();
+
+ if ($response->status() === 201) {
+ $this->note->bluesky_url = $body['url'];
+ $this->note->save();
+
return;
}
- // Make micropub request
- $response = $guzzle->request(
- 'POST',
- 'https://brid.gy/micropub',
- [
- 'headers' => [
- 'Authorization' => 'Bearer ' . config('bridgy.bluesky_token'),
- ],
- 'json' => [
- 'type' => ['h-entry'],
- 'properties' => [
- 'content' => [$this->note->getRawOriginal('note')],
- ],
- ],
- ]
+ throw new \RuntimeException(
+ 'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body())
);
-
- // Parse for syndication URL
- if ($response->getStatusCode() === 201) {
- $this->note->bluesky_url = $response->getHeader('Location')[0];
- $this->note->save();
- }
}
}
diff --git a/app/Jobs/SyndicateNoteToMastodon.php b/app/Jobs/SyndicateNoteToMastodon.php
index b79c092c..3f5cfcd4 100644
--- a/app/Jobs/SyndicateNoteToMastodon.php
+++ b/app/Jobs/SyndicateNoteToMastodon.php
@@ -5,18 +5,22 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
class SyndicateNoteToMastodon implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ /**
+ * Only try once — retrying would send Bridgy a duplicate publish webmention.
+ */
+ public int $tries = 1;
+
/**
* Create a new job instance.
*/
@@ -26,38 +30,26 @@ class SyndicateNoteToMastodon implements ShouldQueue
/**
* Execute the job.
- *
- * @throws GuzzleException
*/
- public function handle(Client $guzzle): void
+ public function handle(): void
{
- // We can only make the request if we have an access token
- if (config('bridgy.mastodon_token') === null) {
+ // no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
+ $response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
+ 'source' => $this->note->uri,
+ 'target' => 'https://brid.gy/publish/mastodon',
+ ]);
+
+ $body = $response->json();
+
+ if ($response->status() === 201) {
+ $this->note->mastodon_url = $body['url'];
+ $this->note->save();
+
return;
}
- // Make micropub request
- $response = $guzzle->request(
- 'POST',
- 'https://brid.gy/micropub',
- [
- 'headers' => [
- 'Authorization' => 'Bearer ' . config('bridgy.mastodon_token'),
- ],
- 'json' => [
- 'type' => ['h-entry'],
- 'properties' => [
- 'content' => [$this->note->getRawOriginal('note')],
- ],
- ],
- ]
+ throw new \RuntimeException(
+ 'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body())
);
-
- // Parse for syndication URL
- if ($response->getStatusCode() === 201) {
- $mastodonUrl = $response->getHeader('Location')[0];
- $this->note->mastodon_url = $mastodonUrl;
- $this->note->save();
- }
}
}
diff --git a/app/Models/About.php b/app/Models/About.php
new file mode 100644
index 00000000..26b6a719
--- /dev/null
+++ b/app/Models/About.php
@@ -0,0 +1,13 @@
+ */
- protected $fillable = [
- 'url',
- 'title',
- 'main',
- 'published',
- ];
-
/** @var array */
protected $casts = [
'created_at' => 'datetime',
@@ -50,6 +40,7 @@ class Article extends Model
return [
'titleurl' => [
'source' => 'title',
+ 'includeTrashed' => true,
],
];
}
@@ -60,8 +51,7 @@ class Article extends Model
get: function () {
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
- $environment->addRenderer(FencedCode::class, new FencedCodeRenderer);
- $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer);
+ $environment->addExtension(new HighlightExtension);
$markdownConverter = new MarkdownConverter($environment);
return $markdownConverter->convert($this->main)->getContent();
@@ -100,7 +90,14 @@ class Article extends Model
protected function link(): Attribute
{
return Attribute::get(
- get: fn () => '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl,
+ get: fn () => '/blog/'.$this->updated_at->year.'/'.$this->updated_at->format('m').'/'.$this->titleurl,
+ );
+ }
+
+ protected function uri(): Attribute
+ {
+ return Attribute::get(
+ get: fn () => config('app.url').$this->link,
);
}
@@ -112,15 +109,15 @@ class Article extends Model
if ($year === null) {
return $query;
}
- $start = $year . '-01-01 00:00:00';
- $end = ($year + 1) . '-01-01 00:00:00';
+ $start = $year.'-01-01 00:00:00';
+ $end = ($year + 1).'-01-01 00:00:00';
if (($month !== null) && ($month !== 12)) {
- $start = $year . '-' . $month . '-01 00:00:00';
- $end = $year . '-' . ($month + 1) . '-01 00:00:00';
+ $start = $year.'-'.$month.'-01 00:00:00';
+ $end = $year.'-'.($month + 1).'-01 00:00:00';
}
if ($month === 12) {
- $start = $year . '-12-01 00:00:00';
- $end = ($year + 1) . '-01-01 00:00:00';
+ $start = $year.'-12-01 00:00:00';
+ $end = ($year + 1).'-01-01 00:00:00';
}
return $query->where([
diff --git a/app/Models/Bookmark.php b/app/Models/Bookmark.php
index 37027e40..1c5ae6e4 100644
--- a/app/Models/Bookmark.php
+++ b/app/Models/Bookmark.php
@@ -4,18 +4,17 @@ declare(strict_types=1);
namespace App\Models;
+use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+#[Fillable(['url', 'name', 'content'])]
class Bookmark extends Model
{
use HasFactory;
- /** @var array */
- protected $fillable = ['url', 'name', 'content'];
-
/** @var array */
protected $casts = [
'syndicates' => 'array',
@@ -26,10 +25,10 @@ class Bookmark extends Model
return $this->belongsToMany('App\Models\Tag');
}
- protected function local_uri(): Attribute
+ protected function localUri(): Attribute
{
return Attribute::get(
- get: fn () => config('app.url') . '/bookmarks/' . $this->id,
+ get: fn () => config('app.url').'/bookmarks/'.$this->id,
);
}
}
diff --git a/app/Models/Contact.php b/app/Models/Contact.php
index 6f193f41..55ec12a8 100644
--- a/app/Models/Contact.php
+++ b/app/Models/Contact.php
@@ -4,28 +4,26 @@ declare(strict_types=1);
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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+#[Table('contacts')]
+#[Fillable(['nick', 'name', 'homepage', 'twitter', 'facebook'])]
class Contact extends Model
{
use HasFactory;
- /** @var string */
- protected $table = 'contacts';
-
- /** @var array */
- protected $fillable = ['nick', 'name', 'homepage', 'twitter', 'facebook'];
-
protected function photo(): Attribute
{
$photo = '/assets/profile-images/default-image';
if (array_key_exists('homepage', $this->attributes) && ! empty($this->attributes['homepage'])) {
$host = parse_url($this->attributes['homepage'], PHP_URL_HOST);
- if (file_exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
- $photo = '/assets/profile-images/' . $host . '/image';
+ if (file_exists(public_path().'/assets/profile-images/'.$host.'/image')) {
+ $photo = '/assets/profile-images/'.$host.'/image';
}
}
diff --git a/app/Models/Like.php b/app/Models/Like.php
index f9ac3bcb..44f25b91 100644
--- a/app/Models/Like.php
+++ b/app/Models/Like.php
@@ -5,20 +5,19 @@ declare(strict_types=1);
namespace App\Models;
use App\Traits\FilterHtml;
+use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Mf2;
+#[Fillable(['url'])]
class Like extends Model
{
use FilterHtml;
use HasFactory;
- /** @var array */
- protected $fillable = ['url'];
-
protected function url(): Attribute
{
return Attribute::set(
diff --git a/app/Models/Media.php b/app/Models/Media.php
index 3d923bed..f6c237d2 100644
--- a/app/Models/Media.php
+++ b/app/Models/Media.php
@@ -4,25 +4,25 @@ declare(strict_types=1);
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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
+#[Table('media_endpoint')]
+#[Fillable(['token', 'path', 'type', 'image_widths'])]
class Media extends Model
{
use HasFactory;
- /** @var string */
- protected $table = 'media_endpoint';
-
- /** @var array */
- protected $fillable = ['token', 'path', 'type', 'image_widths'];
-
- public function note(): BelongsTo
+ public function notes(): BelongsToMany
{
- return $this->belongsTo(Note::class);
+ return $this->belongsToMany(Note::class)
+ ->withPivot('alt_text', 'order')
+ ->withTimestamps();
}
protected function url(): Attribute
@@ -33,7 +33,7 @@ class Media extends Model
return $attributes['path'];
}
- return config('app.url') . '/storage/' . $attributes['path'];
+ return config('app.url').'/storage/'.$attributes['path'];
}
);
}
@@ -78,7 +78,7 @@ class Media extends Model
$basename = $this->getBasename($path);
$extension = $this->getExtension($path);
- return config('app.url') . '/storage/' . $basename . '-' . $size . '.' . $extension;
+ return config('app.url').'/storage/'.$basename.'-'.$size.'.'.$extension;
}
private function getBasename(string $path): string
@@ -89,7 +89,7 @@ class Media extends Model
array_pop($filenameParts);
return ltrim(array_reduce($filenameParts, static function ($carry, $item) {
- return $carry . '.' . $item;
+ return $carry.'.'.$item;
}, ''), '.');
}
diff --git a/app/Models/MicropubClient.php b/app/Models/MicropubClient.php
index 669c7284..f6c70ac2 100644
--- a/app/Models/MicropubClient.php
+++ b/app/Models/MicropubClient.php
@@ -4,20 +4,18 @@ declare(strict_types=1);
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\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
+#[Table('clients')]
+#[Fillable(['client_url', 'client_name'])]
class MicropubClient extends Model
{
use HasFactory;
- /** @var string */
- protected $table = 'clients';
-
- /** @var array */
- protected $fillable = ['client_url', 'client_name'];
-
public function notes(): HasMany
{
return $this->hasMany('App\Models\Note', 'client_id', 'client_url');
diff --git a/app/Models/MicropubToken.php b/app/Models/MicropubToken.php
new file mode 100644
index 00000000..c4df41bc
--- /dev/null
+++ b/app/Models/MicropubToken.php
@@ -0,0 +1,50 @@
+ 'datetime',
+ ];
+ }
+
+ public function revoke(): void
+ {
+ $this->forceFill(['revoked_at' => now()])->save();
+ }
+
+ /**
+ * Find the active (non-revoked) token matching a raw bearer token value.
+ *
+ * Accepts mixed because callers pass request input directly, which PHP
+ * lets be an array (e.g. a client sending token[]=a) - casting that to
+ * string would throw, so anything non-string is just treated as absent.
+ */
+ public static function findActive(mixed $rawToken): ?self
+ {
+ if (! is_string($rawToken) || $rawToken === '') {
+ return null;
+ }
+
+ return self::where('token_hash', hash('sha256', $rawToken))
+ ->whereNull('revoked_at')
+ ->first();
+ }
+
+ protected function isRevoked(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->revoked_at !== null,
+ );
+ }
+}
diff --git a/app/Models/Note.php b/app/Models/Note.php
index 74533443..89ce6b63 100644
--- a/app/Models/Note.php
+++ b/app/Models/Note.php
@@ -6,32 +6,35 @@ namespace App\Models;
use App\CommonMark\Generators\MentionGenerator;
use App\CommonMark\Renderers\MentionRenderer;
-use Codebird\Codebird;
-use Exception;
-use GuzzleHttp\Client;
+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\HasMany;
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\CommonMark\Node\Block\FencedCode;
-use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode;
use League\CommonMark\Extension\Mention\Mention;
use League\CommonMark\Extension\Mention\MentionExtension;
use League\CommonMark\MarkdownConverter;
use Normalizer;
-use Spatie\CommonMarkHighlighter\FencedCodeRenderer;
-use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
+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;
@@ -62,16 +65,6 @@ class Note extends Model
/** @var string */
protected $table = 'notes';
- /** @var array */
- protected $fillable = [
- 'note',
- 'in_reply_to',
- 'client_id',
- ];
-
- /** @var array */
- protected $hidden = ['searchable'];
-
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
@@ -92,9 +85,12 @@ class Note extends Model
return $this->belongsTo(Place::class);
}
- public function media(): HasMany
+ public function media(): BelongsToMany
{
- return $this->hasMany(Media::class);
+ return $this->BelongsToMany(Media::class)
+ ->withPivot('alt_text', 'order')
+ ->withTimestamps()
+ ->orderBy('order');
}
/**
@@ -124,7 +120,7 @@ class Note extends Model
public function getNoteAttribute(?string $value): ?string
{
if ($value === null && $this->place !== null) {
- $value = '📍: ' . $this->place->name . '';
+ $value = '📍: '.$this->place->name.'';
}
// if $value is still null, just return null
@@ -148,13 +144,13 @@ class Note extends Model
foreach ($this->media as $media) {
if ($media->type === 'image') {
- $note .= PHP_EOL . '
';
+ $note .= PHP_EOL.'
';
}
if ($media->type === 'audio') {
- $note .= PHP_EOL . '
+ Tokens
+
+ View and revoke issued Micropub tokens.
+
+
Bio
Edit your bio.
+ About
+
+ Edit your about page.
+
+
Passkeys
Manager your passkeys.
+
+ Settings
+
+ Edit site settings.
+
@stop
diff --git a/resources/views/articles/atom.blade.php b/resources/views/articles/atom.blade.php
deleted file mode 100644
index 9892bcfb..00000000
--- a/resources/views/articles/atom.blade.php
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
- Atom feed for {{ config('user.display_name') }}’s blog
-
- {{ config('app.url')}}/blog
- {{ $articles[0]->updated_at->toAtomString() }}
-
-@foreach($articles as $article)
-
- {{ $article->title }}
-
- {{ config('app.url') }}{{ $article->link }}
- {{ $article->updated_at->toAtomString() }}
- {{ $article->main }}
-
- {{ config('user.display_name') }}
-
-
-@endforeach
-
diff --git a/resources/views/articles/rss.blade.php b/resources/views/articles/rss.blade.php
deleted file mode 100644
index 00268681..00000000
--- a/resources/views/articles/rss.blade.php
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
- {{ config('user.display_name') }}
-
- An RSS feed of the blog posts found on {{ config('app.url') }}
- {{ config('app.url') }}/blog
- {{ $buildDate }}
- 1800
-
-@foreach($articles as $article)
- -
- {{ strip_tags($article->title) }}
-
- main }}
- @if($article->url)
Permalink
@endif
- ]]>
-
- @if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif
- {{ config('app.url') }}{{ $article->link }}
- {{ $article->pubdate }}
-
-@endforeach
-
-
diff --git a/resources/views/bookmarks/index.blade.php b/resources/views/bookmarks/index.blade.php
index 886b940d..4dde1d3c 100644
--- a/resources/views/bookmarks/index.blade.php
+++ b/resources/views/bookmarks/index.blade.php
@@ -7,15 +7,15 @@
@foreach($bookmarks as $bookmark)
@isset($bookmark->content)
{{ $bookmark->content }}
diff --git a/resources/views/bookmarks/tagged.blade.php b/resources/views/bookmarks/tagged.blade.php
index d18fdf60..cb132ce0 100644
--- a/resources/views/bookmarks/tagged.blade.php
+++ b/resources/views/bookmarks/tagged.blade.php
@@ -8,15 +8,15 @@
@foreach($bookmarks as $bookmark)
@isset($bookmark->content)
{{ $bookmark->content }}
diff --git a/resources/views/icons/json-feed.blade.php b/resources/views/icons/json-feed.blade.php
new file mode 100644
index 00000000..7b8df856
--- /dev/null
+++ b/resources/views/icons/json-feed.blade.php
@@ -0,0 +1,11 @@
+@php
+if (isset($title)) {
+ $uniqueId = bin2hex(random_bytes(6));
+}
+@endphp
+
diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php
index 235c6b15..b33d1fa9 100644
--- a/resources/views/master.blade.php
+++ b/resources/views/master.blade.php
@@ -5,15 +5,11 @@
@yield('title'){{ config('app.name') }}
-
+
-
-
-
+
-
-
-
+
@@ -40,37 +36,17 @@
Likes
Contacts
Projects
-
+
About
+
-
-