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 @@ +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..8012f9b8 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; 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/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..a30c89dc 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -71,9 +71,9 @@ class FeedsController extends Controller $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,9 +85,9 @@ 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(), @@ -105,9 +105,9 @@ class FeedsController extends Controller $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'), @@ -144,8 +144,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 +156,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 +192,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..eeb59770 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -5,13 +5,12 @@ declare(strict_types=1); namespace App\Http\Controllers; 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; @@ -199,15 +198,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..c6008a9c 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -6,10 +6,13 @@ 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; @@ -26,9 +29,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,13 +46,36 @@ 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) { + 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', @@ -57,15 +83,10 @@ class MicropubController extends Controller ], 400); } catch (MicropubHandlerException) { 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 (\Exception $e) { return response()->json([ 'error' => 'server_error', 'error_description' => 'An error occurred processing the request', @@ -76,10 +97,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 { diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index fc804ea2..da7c7dc2 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 @@ -111,12 +112,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 +191,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..b9e55139 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -14,11 +14,11 @@ 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('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/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/Article.php b/app/Models/Article.php index bfbd5d51..ab0602d1 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Models; 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\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,29 +14,17 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use League\CommonMark\Environment\Environment; 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\MarkdownConverter; -use Spatie\CommonMarkHighlighter\FencedCodeRenderer; -use Spatie\CommonMarkHighlighter\IndentedCodeRenderer; +use Tempest\Highlight\CommonMark\HighlightExtension; +#[Table('articles')] +#[Fillable(['url', 'title', 'main', 'published'])] class Article extends Model { use HasFactory; use Sluggable; use SoftDeletes; - /** @var string */ - protected $table = 'articles'; - - /** @var array */ - protected $fillable = [ - 'url', - 'title', - 'main', - 'published', - ]; - /** @var array */ protected $casts = [ 'created_at' => 'datetime', @@ -60,8 +50,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 +89,7 @@ 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, ); } @@ -112,15 +101,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/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 . '