diff --git a/.env.example b/.env.example index a1c38110..4eb61db5 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,11 @@ 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 @@ -78,8 +83,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 2803a822..d970fac8 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 90a0bd39..2e8d2bce 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 deleted file mode 100644 index 9d81ed34..00000000 --- a/app/Console/Commands/MigrateMedia.php +++ /dev/null @@ -1,101 +0,0 @@ -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 4e7c58c0..8d5d2c92 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 a27e2738..a6b29176 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 c43a52bd..c6452ba9 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 deleted file mode 100644 index b6c86c47..00000000 --- a/app/Console/Commands/ReprocessMediaImages.php +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 660f233f..00000000 --- a/app/Exceptions/MicropubUnsupportedModelException.php +++ /dev/null @@ -1,7 +0,0 @@ -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); @@ -104,7 +103,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 RedirectResponse|View + * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View */ public function getAvatar(int $contactId) { @@ -113,13 +112,14 @@ 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 = Http::throw()->get($contact->homepage); - } catch (RequestException $e) { - return redirect('/admin/contacts/'.$contactId.'/edit') + $response = $client->get($contact->homepage); + } catch (\GuzzleHttp\Exception\BadResponseException $e) { + return redirect('/admin/contacts/' . $contactId . '/edit') ->with('error', 'Bad resposne from contact’s homepage'); } - $mf2 = \Mf2\parse($response->body(), $contact->homepage); + $mf2 = \Mf2\parse((string) $response->getBody(), $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 = Http::throw()->get($avatarURL); - } catch (RequestException $e) { - return redirect('/admin/contacts/'.$contactId.'/edit') + $avatar = $client->get($avatarURL); + } catch (\GuzzleHttp\Exception\BadResponseException $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->body()); + $filesystem->put($directory . '/image', $avatar->getBody()); 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 8012f9b8..9f635f10 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 26f47b79..e5e82bcd 100644 --- a/app/Http/Controllers/Admin/PlacesController.php +++ b/app/Http/Controllers/Admin/PlacesController.php @@ -120,7 +120,6 @@ class PlacesController extends Controller foreach ($place1->notes as $note) { $note->place()->dissociate(); $note->place()->associate($place2->id); - $note->save(); } $place1->delete(); } @@ -128,7 +127,6 @@ 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 ff28fd50..9ab860d7 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 13989eac..280cc3ed 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 a30c89dc..eb0847a3 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 eeb59770..45b488da 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -5,12 +5,13 @@ 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; @@ -198,13 +199,15 @@ 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 = Http::throw()->get($clientId); - } catch (\Throwable) { + $clientInfo = $guzzle->get($clientId); + } catch (Exception) { return false; } - $clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId); + $clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId); $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index c6008a9c..758b3255 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -6,13 +6,10 @@ 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; @@ -29,9 +26,9 @@ class MicropubController extends Controller /** * Respond to a POST request to the micropub endpoint. * - * 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(). + * 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. */ public function post(MicropubRequest $request): JsonResponse { @@ -46,36 +43,13 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); - $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']); - } + $result = $handler->handle($request->getMicropubData()); + // 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', @@ -83,10 +57,15 @@ class MicropubController extends Controller ], 400); } catch (MicropubHandlerException) { return response()->json([ - 'error' => 'unsupported_operation', + 'error' => 'Unknown Micropub type', 'error_description' => 'The request could not be processed by this server', ], 500); - } catch (\Exception $e) { + } catch (InvalidTokenScopeException) { + return response()->json([ + 'error' => 'invalid_scope', + 'error_description' => 'The token does not have the required scope for this request', + ], 403); + } catch (\Exception) { return response()->json([ 'error' => 'server_error', 'error_description' => 'An error occurred processing the request', @@ -97,9 +76,10 @@ class MicropubController extends Controller /** * Respond to a GET request to the micropub endpoint. * - * 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. + * 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. */ public function get(Request $request): JsonResponse { diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index da7c7dc2..fc804ea2 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -13,10 +13,9 @@ 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 @@ -112,9 +111,12 @@ class MicropubMediaController extends Controller $filename = Storage::disk('local')->putFile('media', $file); + /** @var ImageManager $manager */ + $manager = resolve(ImageManager::class); try { - $width = Image::fromUpload($request->file('file'))->width(); - } catch (ImageException) { + $image = $manager->read($request->file('file')); + $width = $image->width(); + } catch (Exception) { // not an image $width = null; } @@ -191,7 +193,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 ab81002b..d5c9bc90 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -5,6 +5,7 @@ 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; @@ -52,7 +53,7 @@ class NotesController extends Controller ->withCount(['webmentions AS reposts' => function ($query) { $query->where('type', 'repost-of'); }])->firstOrFail(); - } catch (\Exception) { + } catch (ModelNotFoundException $exception) { abort(404); } @@ -64,7 +65,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 b9e55139..467283db 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 1e06ab05..a6a6c8c4 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(Request): (Response) $next + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\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 ab4fde74..41c70280 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -5,9 +5,12 @@ 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 [ @@ -15,25 +18,102 @@ 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()) { - $data = $this->json()->all(); + $this->normalizeMicropubJson(); + } else { + $this->normalizeMicropubForm(); + } + } - if (isset($data['action']) && $data['action'] === 'update') { - return 'update'; + 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); } + } + // Or set the type to update + elseif (isset($data['action']) && $data['action'] === 'update') { + $this->micropubData['type'] = 'update'; + } - if (isset($data['type']) && is_array($data['type'])) { - $type = current($data['type']); - if (str_starts_with($type, 'h-')) { - return substr($type, 2); - } - } + // Add in the token data + $this->micropubData['token_data'] = $data['token_data']; + // 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; } - return $this->input('h') ?: null; + if (Arr::has($data, 'properties.location.0')) { + return Arr::get($data, 'properties.location.0'); + } + + return Arr::get($data, 'properties.location'); } } diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php index 0cb073d5..3c187dd4 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 RequestException + * @throws GuzzleException * @throws FileNotFoundException */ - public function handle(): void + public function handle(Client $guzzle): void { - // 4XX and 5XX responses should throw so Laravel can catch and - // retry these automatically. - $response = Http::throw()->get($this->source); - if ($response->status() === 200) { + $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) { $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, - $response->body() + (string) $response->getBody() ); // remove backup if the same if ($filesystem->exists($filenameBackup)) { diff --git a/app/Jobs/NotifyBrrrOfWebMention.php b/app/Jobs/NotifyBrrrOfWebMention.php deleted file mode 100644 index 3273b7d6..00000000 --- a/app/Jobs/NotifyBrrrOfWebMention.php +++ /dev/null @@ -1,56 +0,0 @@ - $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 3ed065c1..3c6028a9 100644 --- a/app/Jobs/ProcessLike.php +++ b/app/Jobs/ProcessLike.php @@ -5,13 +5,16 @@ 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; @@ -31,11 +34,42 @@ class ProcessLike implements ShouldQueue /** * Execute the job. + * + * @throws GuzzleException */ - public function handle(Authorship $authorship): int + public function handle(Client $client, Authorship $authorship): int { - $response = Http::throw()->get($this->like->url); - $mf2 = \Mf2\parse($response->body(), $this->like->url); + 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); if (Arr::has($mf2, 'items.0.properties.content')) { $this->like->content = $mf2['items'][0]['properties']['content'][0]['html']; } @@ -57,4 +91,15 @@ 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 78aeba3e..b7f36648 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,38 +30,40 @@ class ProcessMedia implements ShouldQueue /** * Execute the job. */ - public function handle(): void + public function handle(ImageManager $manager): void { // Load file - $file = Storage::disk('local')->get($this->filename); + $file = Storage::disk('local')->get('media/' . $this->filename); // Open file - $image = Image::fromStorage($this->filename, 'local'); try { - $width = $image->width(); - } catch (ImageException) { + $image = $manager->read($file); + } catch (DecoderException) { // not an image; delete file and end job - Storage::disk('local')->delete($this->filename); + Storage::disk('local')->delete('media/' . $this->filename); return; } // Save the file publicly - Storage::disk('public')->put($this->filename, $file); + Storage::disk('public')->put('media/' . $this->filename, $file); // Create smaller versions if necessary - if ($width > 1000) { + if ($image->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), '.'); - Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes()); - Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes()); + $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()); } // Now we can delete the locally saved image - Storage::disk('local')->delete($this->filename); + Storage::disk('local')->delete('media/' . $this->filename); } } diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index 4ac6f5fd..d92dfa18 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -7,12 +7,13 @@ 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; @@ -35,20 +36,18 @@ class ProcessWebMention implements ShouldQueue * Execute the job. * * @throws RemoteContentNotFoundException + * @throws GuzzleException * @throws InvalidMentionException */ - public function handle(Parser $parser): void + public function handle(Parser $parser, Client $guzzle): void { try { - $response = Http::get($this->source); - } catch (ConnectionException) { + $response = $guzzle->request('GET', $this->source); + } catch (RequestException $e) { throw new RemoteContentNotFoundException; } - if ($response->failed()) { - throw new RemoteContentNotFoundException; - } - $this->saveRemoteContent($response->body(), $this->source); - $microformats = Mf2\parse($response->body(), $this->source); + $this->saveRemoteContent((string) $response->getBody(), $this->source); + $microformats = Mf2\parse((string) $response->getBody(), $this->source); $webmentions = WebMention::where('source', $this->source)->get(); foreach ($webmentions as $webmention) { // check webmention still references target @@ -96,7 +95,6 @@ class ProcessWebMention implements ShouldQueue $webmention->type = $type; $webmention->mf2 = json_encode($microformats); $webmention->save(); - dispatch(new NotifyBrrrOfWebMention($webmention)); } /** @@ -112,7 +110,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 aa7d8af7..08152d5b 100644 --- a/app/Jobs/SaveProfileImage.php +++ b/app/Jobs/SaveProfileImage.php @@ -4,14 +4,13 @@ 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; @@ -56,18 +55,20 @@ 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 = Http::throw()->get($photo); - $image = $response->body(); - } catch (ConnectionException|RequestException) { + $response = $client->get($photo); + $image = $response->getBody(); + } catch (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 b72da7b0..0e07efbd 100755 --- a/app/Jobs/SaveScreenshot.php +++ b/app/Jobs/SaveScreenshot.php @@ -5,15 +5,14 @@ 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 { @@ -28,68 +27,77 @@ class SaveScreenshot implements ShouldQueue /** * Execute the job. + * + * + * @throws JsonException */ public function handle(): void { - $cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2') - ->withToken(config('services.cloudconvert.token')) - ->throw(); + // A normal Guzzle client + $client = resolve(Client::class); + // A Guzzle client with a custom Middleware to retry the CloudConvert API requests + $retryClient = resolve('RetryGuzzle'); // First request that CloudConvert takes a screenshot of the URL - $takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [ - 'url' => $this->bookmark->url, - 'output_format' => 'png', - 'screen_width' => 1440, - 'screen_height' => 900, - 'wait_until' => 'networkidle0', - 'wait_time' => 100, + $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, + ], ]); - $taskId = $takeScreenshotJobResponse->json('data.id'); + $taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; // Now wait till the status job is finished - $screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId); - - $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 = $cloudConvert->post('/export/url', [ - 'input' => $finishedCaptureId, - 'archive_multiple_files' => false, + $screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $taskId, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'), + ], + 'query' => [ + 'include' => 'payload', + ], ]); - $exportImageJobId = $exportImageJob->json('data.id'); + $finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->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, + ], + ]); + + $exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; // Again, wait till the status of this export job is finished - $finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId); + $finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $exportImageJobId, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'), + ], + 'query' => [ + 'include' => 'payload', + ], + ]); // Now we can download the screenshot and save it to the storage - $finalImageUrl = $finalImageUrlResponse->json('data.result.files.0.url'); + $finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url; - $finalImageUrlContent = Http::throw()->get($finalImageUrl); + $finalImageUrlContent = $client->request('GET', $finalImageUrl); - Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->body()); + Storage::disk('public')->put('/assets/img/bookmarks/' . $taskId . '.png', $finalImageUrlContent->getBody()->getContents()); $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 d8e962e3..2ff5f2c6 100644 --- a/app/Jobs/SendWebMentions.php +++ b/app/Jobs/SendWebMentions.php @@ -5,6 +5,8 @@ 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; @@ -12,9 +14,7 @@ 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,6 +31,8 @@ class SendWebMentions implements ShouldQueue /** * Execute the job. + * + * @throws GuzzleException */ public function handle(): void { @@ -40,9 +42,12 @@ class SendWebMentions implements ShouldQueue foreach ($urls as $url) { $endpoint = $this->discoverWebmentionEndpoint($url); if ($endpoint !== null) { - Http::asForm()->post($endpoint, [ - 'source' => $this->note->uri, - 'target' => $url, + $guzzle = resolve(Client::class); + $guzzle->post($endpoint, [ + 'form_params' => [ + 'source' => $this->note->uri, + 'target' => $url, + ], ]); } } @@ -50,6 +55,8 @@ class SendWebMentions implements ShouldQueue /** * Discover if a URL has a webmention endpoint. + * + * @throws GuzzleException */ public function discoverWebmentionEndpoint(string $url): ?string { @@ -63,9 +70,10 @@ class SendWebMentions implements ShouldQueue $endpoint = null; - $response = Http::get($url); + $guzzle = resolve(Client::class); + $response = $guzzle->get($url); // check HTTP Headers for webmention endpoint - $links = Header::parse($response->header('Link')); + $links = Header::parse($response->getHeader('Link')); foreach ($links as $link) { if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { return $this->resolveUri(trim($link[0], '<>'), $url); @@ -73,13 +81,9 @@ class SendWebMentions implements ShouldQueue } // failed to find a header so parse HTML - $html = $response->body(); + $html = (string) $response->getBody(); - if ($html === '') { - return null; - } - - $mf2 = new Parser($html, $url); + $mf2 = new \Mf2\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 582ef760..e815be34 100644 --- a/app/Jobs/SyndicateNoteToBluesky.php +++ b/app/Jobs/SyndicateNoteToBluesky.php @@ -5,22 +5,18 @@ 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. */ @@ -30,26 +26,37 @@ class SyndicateNoteToBluesky implements ShouldQueue /** * Execute the job. + * + * @throws GuzzleException */ - public function handle(): void + public function handle(Client $guzzle): void { - // 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(); - + // We can only make the request if we have an access token + if (config('bridgy.bluesky_token') === null) { return; } - throw new \RuntimeException( - 'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body()) + // 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')], + ], + ], + ] ); + + // 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 3f5cfcd4..b79c092c 100644 --- a/app/Jobs/SyndicateNoteToMastodon.php +++ b/app/Jobs/SyndicateNoteToMastodon.php @@ -5,22 +5,18 @@ 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. */ @@ -30,26 +26,38 @@ class SyndicateNoteToMastodon implements ShouldQueue /** * Execute the job. + * + * @throws GuzzleException */ - public function handle(): void + public function handle(Client $guzzle): void { - // 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(); - + // We can only make the request if we have an access token + if (config('bridgy.mastodon_token') === null) { return; } - throw new \RuntimeException( - 'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body()) + // 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')], + ], + ], + ] ); + + // 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 ab0602d1..bfbd5d51 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -5,8 +5,6 @@ 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; @@ -14,17 +12,29 @@ 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 Tempest\Highlight\CommonMark\HighlightExtension; +use Spatie\CommonMarkHighlighter\FencedCodeRenderer; +use Spatie\CommonMarkHighlighter\IndentedCodeRenderer; -#[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', @@ -50,7 +60,8 @@ class Article extends Model get: function () { $environment = new Environment; $environment->addExtension(new CommonMarkCoreExtension); - $environment->addExtension(new HighlightExtension); + $environment->addRenderer(FencedCode::class, new FencedCodeRenderer); + $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer); $markdownConverter = new MarkdownConverter($environment); return $markdownConverter->convert($this->main)->getContent(); @@ -89,7 +100,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, ); } @@ -101,15 +112,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 1c5ae6e4..37027e40 100644 --- a/app/Models/Bookmark.php +++ b/app/Models/Bookmark.php @@ -4,17 +4,18 @@ 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', @@ -25,10 +26,10 @@ class Bookmark extends Model return $this->belongsToMany('App\Models\Tag'); } - protected function localUri(): Attribute + protected function local_uri(): 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 55ec12a8..6f193f41 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -4,26 +4,28 @@ 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 44f25b91..f9ac3bcb 100644 --- a/app/Models/Like.php +++ b/app/Models/Like.php @@ -5,19 +5,20 @@ 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 f6c237d2..3d923bed 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\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Str; -#[Table('media_endpoint')] -#[Fillable(['token', 'path', 'type', 'image_widths'])] class Media extends Model { use HasFactory; - public function notes(): BelongsToMany + /** @var string */ + protected $table = 'media_endpoint'; + + /** @var array */ + protected $fillable = ['token', 'path', 'type', 'image_widths']; + + public function note(): BelongsTo { - return $this->belongsToMany(Note::class) - ->withPivot('alt_text', 'order') - ->withTimestamps(); + return $this->belongsTo(Note::class); } 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 f6c70ac2..669c7284 100644 --- a/app/Models/MicropubClient.php +++ b/app/Models/MicropubClient.php @@ -4,18 +4,20 @@ 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 89ce6b63..74533443 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -6,35 +6,32 @@ namespace App\Models; use App\CommonMark\Generators\MentionGenerator; use App\CommonMark\Renderers\MentionRenderer; -use App\Observers\NoteObserver; -use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Attributes\Hidden; -use Illuminate\Database\Eloquent\Attributes\ObservedBy; -use Illuminate\Database\Eloquent\Attributes\Table; +use Codebird\Codebird; +use Exception; +use GuzzleHttp\Client; 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 Tempest\Highlight\CommonMark\HighlightExtension; +use Spatie\CommonMarkHighlighter\FencedCodeRenderer; +use Spatie\CommonMarkHighlighter\IndentedCodeRenderer; -#[Table('notes')] -#[Fillable(['note', 'in_reply_to', 'client_id'])] -#[Hidden(['searchable'])] -#[ObservedBy(NoteObserver::class)] class Note extends Model { use HasFactory; @@ -65,6 +62,16 @@ 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); @@ -85,12 +92,9 @@ class Note extends Model return $this->belongsTo(Place::class); } - public function media(): BelongsToMany + public function media(): HasMany { - return $this->BelongsToMany(Media::class) - ->withPivot('alt_text', 'order') - ->withTimestamps() - ->orderBy('order'); + return $this->hasMany(Media::class); } /** @@ -120,7 +124,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 @@ -144,13 +148,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.'