diff --git a/.editorconfig b/.editorconfig index 5a999757..0b5d680f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,18 +1,21 @@ -# EditorConfig is awesome: http://EditorConfig.org - -# top-most EditorConfig file root = true -# Unix-style newlines with a newline ending every file [*] -end_of_line = lf charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space insert_final_newline = true trim_trailing_whitespace = true -indent_style = space -indent_size = 4 -# Tab indentation -[Makefile] -indent_style = tab -tab_width = 4 +[*.{js,css}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/.env.dusk.testing b/.env.dusk.testing deleted file mode 100644 index 756f4074..00000000 --- a/.env.dusk.testing +++ /dev/null @@ -1,14 +0,0 @@ -APP_ENV=testing -APP_DEBUG=true -APP_KEY=base64:6DJhvZLVjE6dD4Cqrteh+6Z5vZlG+v/soCKcDHLOAH0= -APP_URL=http://localhost:8000 -APP_LONGURL=localhost -APP_SHORTURL=local - -DB_CONNECTION=travis - -CACHE_DRIVER=array -SESSION_DRIVER=file -QUEUE_DRIVER=sync - -SCOUT_DRIVER=pgsql diff --git a/.env.example b/.env.example index 21e860aa..4eb61db5 100644 --- a/.env.example +++ b/.env.example @@ -1,62 +1,90 @@ APP_NAME=Laravel -APP_ENV=production -APP_KEY=SomeRandomString # Leave this -APP_DEBUG=false -APP_LOG_LEVEL=warning +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_TIMEZONE=UTC +APP_URL=https://example.com -DB_CONNECTION=pgsql +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=mysql DB_HOST=127.0.0.1 -DB_PORT=5432 -DB_DATABASE= -DB_USERNAME= +DB_PORT=3306 +DB_DATABASE=laravel +DB_USERNAME=root DB_PASSWORD= -BROADCAST_DRIVER=log -CACHE_DRIVER=file -SESSION_DRIVER=file -QUEUE_DRIVER=sync +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_DRIVER=smtp -MAIL_HOST=smtp.mailtrap.io +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" -PUSHER_APP_ID= -PUSHER_APP_KEY= -PUSHER_APP_SECRET= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false -AWS_S3_KEY=your-key -AWS_S3_SECRET=your-secret -AWS_S3_REGION=region -AWS_S3_BUCKET=your-bucket -AWS_S3_URL=https://xxxxxxx.s3-region.amazonaws.com +VITE_APP_NAME="${APP_NAME}" -APP_URL=https://example.com # This one is necessary -APP_LONGURL=example.com -APP_SHORTURL=examp.le - -ADMIN_USER=admin # pick something better, this is used for `/admin` +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 +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=pgsql +SCOUT_DRIVER=database +SCOUT_QUEUE=false -PIWIK=false -PIWIK_ID=1 -PIWIK_URL=https://analytics.jmb.lv/piwik.php +SESSION_SECURE_COOKIE=true +SESSION_SAME_SITE=strict -APP_TIMEZONE=UTC -APP_LANG=en -APP_LOG=daily -SECURE_SESSION_COOKIE=true +LOG_SLACK_WEBHOOK_URL= + +FLARE_KEY= + +IGNITION_OPEN_AI_KEY= + +BRIDGY_MASTODON_TOKEN= diff --git a/.env.travis b/.env.travis deleted file mode 100644 index 177e5cc8..00000000 --- a/.env.travis +++ /dev/null @@ -1,17 +0,0 @@ -APP_ENV=testing -APP_DEBUG=true -APP_KEY=base64:6DJhvZLVjE6dD4Cqrteh+6Z5vZlG+v/soCKcDHLOAH0= -APP_URL=http://jonnybarnes.localhost:8000 -APP_LONGURL=jonnybarnes.localhost -APP_SHORTURL=jmb.localhost - -DB_CONNECTION=travis - -CACHE_DRIVER=array -SESSION_DRIVER=array -QUEUE_DRIVER=sync - -SCOUT_DRIVER=pgsql - -DISPLAY_NAME='Travis Test' -USER_NAME=travis diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index b6ca2fd4..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,24 +0,0 @@ -parserOptions: - sourceType: 'module' -extends: 'eslint:recommended' -env: - browser: true - es6: true -rules: - indent: - - error - - 4 - linebreak-style: - - error - - unix - quotes: - - error - - single - semi: - - error - - always - no-console: - - error - - allow: - - warn - - error diff --git a/.gitattributes b/.gitattributes index 967315dd..78f41d7a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ -* text=auto -*.css linguist-vendored -*.scss linguist-vendored -*.js linguist-vendored -CHANGELOG.md export-ignore +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php diff --git a/.gitignore b/.gitignore index 27dc57b2..a0c2459a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,24 @@ +/.phpunit.cache /node_modules +/public/build +/public/coverage /public/hot +/public/files /public/storage /storage/*.key /vendor -/.idea -/.vagrant -Homestead.yaml +.env +.env.backup +.env.production +.phpunit.result.cache Homestead.json +Homestead.yaml +auth.json npm-debug.log yarn-error.log -.env -/public/files -/public/keybase.txt -/coverage -/LegacyTests +/.fleet +/.idea +/.vscode +ray.php +/public/gpg.key +/public/assets/img/favicon.png diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index 5e728eb1..00000000 --- a/.styleci.yml +++ /dev/null @@ -1,9 +0,0 @@ -preset: laravel - -disabled: - - concat_without_spaces - - simplified_null_return - - single_import_per_statement - -finder: - path: app/ diff --git a/.stylelintrc b/.stylelintrc index 6449c3f2..a9a9091b 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,6 +1,3 @@ { - "extends": "stylelint-config-standard", - "rules": { - "indentation": 4 - } + "extends": ["stylelint-config-standard"] } diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f99b6ea4..00000000 --- a/.travis.yml +++ /dev/null @@ -1,69 +0,0 @@ -language: php - -sudo: false -dist: trusty - -cache: - - apt - -addons: - hosts: - - jmb.localhost - - jonnybarnes.localhost - postgresql: "9.6" - apt: - packages: - - nginx-full - - realpath - - postgresql-9.6-postgis-2.3 - - imagemagick - #- google-chrome-stable - artifacts: - s3_region: "eu-west-1" - paths: - - $(ls tests/Browser/screenshots/*.png | tr "\n" ":") - - $(ls tests/Browser/console/*.log | tr "\n" ":") - - $(ls storage/logs/*.log | tr "\n" ":") - - $(ls /tmp/*.log | tr "\n" ":") - -services: - - postgresql - -env: - global: - - setup=basic - -php: - - 7.2 - -before_install: - - printf "\n" | pecl install imagick - - cp .env.travis .env - - echo 'error_log = "/tmp/php.error.log"' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini - - psql -U travis -c 'create database travis_ci_test' - - psql -U travis -d travis_ci_test -c 'create extension postgis' - - travis_retry composer self-update --preview - - pear install pear/PHP_CodeSniffer && phpenv rehash - -install: - - if [[ $setup = 'basic' ]]; then travis_retry composer install --no-interaction --prefer-dist; fi - - if [[ $setup = 'stable' ]]; then travis_retry composer update --no-interaction --prefer-dist --prefer-stable; fi - - if [[ $setup = 'lowest' ]]; then travis_retry composer update --no-interaction --prefer-dist --prefer-lowest --prefer-stable; fi - - travis/install-nginx.sh - - . $HOME/.nvm/nvm.sh - - nvm install stable - - nvm use stable - - npm i puppeteer - -before_script: - - php artisan key:generate - - php artisan migrate - - php artisan db:seed - #- google-chrome-stable --headless --disable-gpu --remote-debugging-port=9515 http://localhost:8000 & - #- sleep 5 - -script: - - php vendor/bin/phpunit --coverage-text - - phpcs - #- php artisan dusk - - php vendor/bin/security-checker security:check --end-point=http://security.sensiolabs.org/check_lock diff --git a/app/CommonMark/Generators/MentionGenerator.php b/app/CommonMark/Generators/MentionGenerator.php new file mode 100644 index 00000000..2ac1a797 --- /dev/null +++ b/app/CommonMark/Generators/MentionGenerator.php @@ -0,0 +1,17 @@ +getIdentifier())->first(); + + // If we have a contact, render a mini-hcard + if ($contact) { + // rendering a blade template to a string, so can’t be an HtmlElement + return trim(view('templates.mini-hcard', ['contact' => $contact])->render()); + } + + // Otherwise, check the link is to the Mastodon profile + $mentionText = $node->getIdentifier(); + $parts = explode('@', $mentionText); + + // 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); + } + + // Render the Mastodon profile link + 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 new file mode 100644 index 00000000..2e8d2bce --- /dev/null +++ b/app/Console/Commands/CopyMediaToLocal.php @@ -0,0 +1,69 @@ +path; + + $this->info('Processing: ' . $filename); + + // If the file is already saved locally skip to next one + if (Storage::disk('local')->exists('public/' . $filename)) { + $this->info('File already exists locally, skipping'); + + continue; + } + + // Copy the file from S3 to the local filesystem + if (! Storage::disk('s3')->exists($filename)) { + $this->error('File does not exist on S3'); + + continue; + } + $contents = Storage::disk('s3')->get($filename); + 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; + if (Storage::disk('s3')->exists($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)); + } + } + } +} diff --git a/app/Console/Commands/MigratePlaceDataFromPostgis.php b/app/Console/Commands/MigratePlaceDataFromPostgis.php new file mode 100644 index 00000000..8d5d2c92 --- /dev/null +++ b/app/Console/Commands/MigratePlaceDataFromPostgis.php @@ -0,0 +1,75 @@ +exists) { + $this->info('There is no Postgis location data in the table. Exiting.'); + + return 0; + } + + $latitudeColumn = DB::selectOne(DB::raw(" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'places' + AND column_name = 'latitude' + ) + ")); + + if (! $latitudeColumn->exists) { + $this->error('Latitude and longitude columns have not been created yet'); + + return 1; + } + + $places = Place::all(); + + $places->each(function ($place) { + $this->info('Extracting Postgis data for place: ' . $place->name); + + $place->latitude = $place->location->getLat(); + $place->longitude = $place->location->getLng(); + $place->save(); + }); + + return 0; + } +} diff --git a/app/Console/Commands/ParseCachedWebMentions.php b/app/Console/Commands/ParseCachedWebMentions.php index 2183cd4a..a6b29176 100644 --- a/app/Console/Commands/ParseCachedWebMentions.php +++ b/app/Console/Commands/ParseCachedWebMentions.php @@ -6,6 +6,7 @@ namespace App\Console\Commands; use App\Models\WebMention; use Illuminate\Console\Command; +use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\FileSystem\FileSystem; class ParseCachedWebMentions extends Command @@ -24,32 +25,22 @@ class ParseCachedWebMentions extends Command */ protected $description = 'Re-parse the webmention’s cached HTML'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * - * @return mixed + * @throws FileNotFoundException */ - public function handle(FileSystem $filesystem) + public function handle(FileSystem $filesystem): void { - $HTMLfiles = $filesystem->allFiles(storage_path() . '/HTML'); - foreach ($HTMLfiles as $file) { - if ($file->getExtension() != 'backup') { //we don’t want to parse.backup files + $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); $html = $filesystem->get($filepath); - $url = $this->URLFromFilename($filepath); - $microformats = \Mf2\parse($html, $url); + $url = $this->urlFromFilename($filepath); $webmention = WebMention::where('source', $url)->firstOrFail(); + $microformats = \Mf2\parse($html, $url); $webmention->mf2 = json_encode($microformats); $webmention->save(); $this->info('Saved the microformats to the database.'); @@ -59,16 +50,13 @@ class ParseCachedWebMentions extends Command /** * Determine the source URL from a filename. - * - * @param string - * @return string */ - private function URLFromFilename(string $filepath): string + private function urlFromFilename(string $filepath): string { $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, mb_strlen($url) - 10); + if (mb_substr($url, -10) === 'index.html') { + $url = mb_substr($url, 0, -10); } return $url; diff --git a/app/Console/Commands/ReDownloadWebMentions.php b/app/Console/Commands/ReDownloadWebMentions.php index 1bd1f93b..c6452ba9 100644 --- a/app/Console/Commands/ReDownloadWebMentions.php +++ b/app/Console/Commands/ReDownloadWebMentions.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Jobs\DownloadWebMention; use App\Models\WebMention; use Illuminate\Console\Command; -use App\Jobs\DownloadWebMention; class ReDownloadWebMentions extends Command { @@ -24,22 +24,10 @@ class ReDownloadWebMentions extends Command */ protected $description = 'Redownload the HTML content of webmentions'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. - * - * @return mixed */ - public function handle() + public function handle(): void { $webmentions = WebMention::all(); foreach ($webmentions as $webmention) { diff --git a/app/Console/Commands/SecurityCheck.php b/app/Console/Commands/SecurityCheck.php deleted file mode 100644 index acd014ab..00000000 --- a/app/Console/Commands/SecurityCheck.php +++ /dev/null @@ -1,66 +0,0 @@ -securityChecker = $securityChecker; - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle(): int - { - $alerts = $this->securityChecker->check(base_path() . '/composer.lock'); - if (count($alerts) === 0) { - $this->info('No security vulnerabilities found.'); - - return 0; - } - $this->error('vulnerabilities found'); - - return 1; - } -} diff --git a/app/Console/Commands/UpdateWebmentionsRelationship.php b/app/Console/Commands/UpdateWebmentionsRelationship.php new file mode 100644 index 00000000..f5bc1114 --- /dev/null +++ b/app/Console/Commands/UpdateWebmentionsRelationship.php @@ -0,0 +1,36 @@ +where('commentable_type', '=', 'App\Model\Note') + ->update(['commentable_type' => Note::class]); + + $this->info('All webmentions updated to relate to the correct note model class'); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index cec36b39..432844ad 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -7,34 +7,19 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel; class Kernel extends ConsoleKernel { - /** - * The Artisan commands provided by your application. - * - * @var array - */ - protected $commands = [ - Commands\SecurityCheck::class, - Commands\ParseCachedWebMentions::class, - Commands\ReDownloadWebMentions::class, - ]; - /** * Define the application's command schedule. - * - * @param \Illuminate\Console\Scheduling\Schedule $schedule - * @return void */ - protected function schedule(Schedule $schedule) + protected function schedule(Schedule $schedule): void { $schedule->command('horizon:snapshot')->everyFiveMinutes(); + $schedule->command('cache:prune-stale-tags')->hourly(); } /** * Register the commands for the application. - * - * @return void */ - protected function commands() + protected function commands(): void { $this->load(__DIR__.'/Commands'); diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 971523ba..cb48444a 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,61 +2,18 @@ namespace App\Exceptions; -use Exception; -use Illuminate\Support\Facades\Route; -use Illuminate\Session\TokenMismatchException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Throwable; -/** - * @codeCoverageIgnore - */ class Handler extends ExceptionHandler { /** - * A list of the exception types that are not reported. - * - * @var array + * Register the exception handling callbacks for the application. */ - protected $dontReport = [ - // - ]; - - /** - * A list of the inputs that are never flashed for validation exceptions. - * - * @var array - */ - protected $dontFlash = [ - 'password', - 'password_confirmation', - ]; - - /** - * Report or log an exception. - * - * This is a great spot to send exceptions to Sentry, Bugsnag, etc. - * - * @param \Exception $exception - * @return void - */ - public function report(Exception $exception) + public function register(): void { - parent::report($exception); - } - - /** - * Render an exception into an HTTP response. - * - * @param \Illuminate\Http\Request $request - * @param \Exception $exception - * @return \Illuminate\Http\Response - */ - public function render($request, Exception $exception) - { - if ($exception instanceof TokenMismatchException) { - Route::getRoutes()->match($request); - } - - return parent::render($request, $exception); + $this->reportable(function (Throwable $_e) { + // + }); } } diff --git a/app/Exceptions/InternetArchiveException.php b/app/Exceptions/InternetArchiveException.php index 7e810fea..99d5cab7 100644 --- a/app/Exceptions/InternetArchiveException.php +++ b/app/Exceptions/InternetArchiveException.php @@ -2,6 +2,4 @@ namespace App\Exceptions; -class InternetArchiveException extends \Exception -{ -} +class InternetArchiveException extends \Exception {} diff --git a/app/Exceptions/InvalidTokenException.php b/app/Exceptions/InvalidTokenException.php deleted file mode 100644 index 8184cfa7..00000000 --- a/app/Exceptions/InvalidTokenException.php +++ /dev/null @@ -1,13 +0,0 @@ -orderBy('id', 'desc')->get(); @@ -24,11 +18,6 @@ class ArticlesController extends Controller return view('admin.articles.index', ['posts' => $posts]); } - /** - * Show the new article form. - * - * @return \Illuminate\View\View - */ public function create(): View { $message = session('message'); @@ -36,55 +25,29 @@ class ArticlesController extends Controller return view('admin.articles.create', ['message' => $message]); } - /** - * Process an incoming request for a new article and save it. - * - * @return \Illuminate\Http\RedirectResponse - */ public function store(): RedirectResponse { - //if a `.md` is attached use that for the main content. + // if a `.md` is attached use that for the main content. if (request()->hasFile('article')) { $file = request()->file('article')->openFile(); $content = $file->fread($file->getSize()); } $main = $content ?? request()->input('main'); - $article = Article::create( - [ - 'url' => request()->input('url'), - 'title' => request()->input('title'), - 'main' => $main, - 'published' => request()->input('published') ?? 0, - ] - ); + Article::create([ + 'url' => request()->input('url'), + 'title' => request()->input('title'), + 'main' => $main, + 'published' => request()->input('published') ?? 0, + ]); return redirect('/admin/blog'); } - /** - * Show the edit form for an existing article. - * - * @param int $articleId - * @return \Illuminate\View\View - */ - public function edit(int $articleId): View + public function edit(Article $article): View { - $post = Article::select( - 'title', - 'main', - 'url', - 'published' - )->where('id', $articleId)->get(); - - return view('admin.articles.edit', ['id' => $articleId, 'post' => $post]); + return view('admin.articles.edit', ['article' => $article]); } - /** - * Process an incoming request to edit an article. - * - * @param int $articleId - * @return \Illuminate\Http\RedirectResponse - */ public function update(int $articleId): RedirectResponse { $article = Article::find($articleId); @@ -97,12 +60,6 @@ class ArticlesController extends Controller return redirect('/admin/blog'); } - /** - * Process a request to delete an aricle. - * - * @param int $articleId - * @return \Illuminate\Http\RedirectResponse - */ public function destroy(int $articleId): RedirectResponse { Article::where('id', $articleId)->delete(); diff --git a/app/Http/Controllers/Admin/BioController.php b/app/Http/Controllers/Admin/BioController.php new file mode 100644 index 00000000..c760e12c --- /dev/null +++ b/app/Http/Controllers/Admin/BioController.php @@ -0,0 +1,32 @@ + $bio, + ]); + } + + public function update(Request $request): RedirectResponse + { + $bio = Bio::firstOrNew(); + $bio->content = $request->input('content'); + $bio->save(); + + return redirect()->route('admin.bio.show'); + } +} diff --git a/app/Http/Controllers/Admin/ClientsController.php b/app/Http/Controllers/Admin/ClientsController.php index 9eb37c97..38524b62 100644 --- a/app/Http/Controllers/Admin/ClientsController.php +++ b/app/Http/Controllers/Admin/ClientsController.php @@ -4,18 +4,15 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; -use Illuminate\View\View; -use Illuminate\Http\Request; -use App\Models\MicropubClient; use App\Http\Controllers\Controller; +use App\Models\MicropubClient; use Illuminate\Http\RedirectResponse; +use Illuminate\View\View; class ClientsController extends Controller { /** * Show a list of known clients. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -26,8 +23,6 @@ class ClientsController extends Controller /** * Show form to add a client name. - * - * @return \Illuminate\View\View */ public function create(): View { @@ -36,8 +31,6 @@ class ClientsController extends Controller /** * Process the request to adda new client name. - * - * @return \Illuminate\Http\RedirectResponse */ public function store(): RedirectResponse { @@ -51,9 +44,6 @@ class ClientsController extends Controller /** * Show a form to edit a client name. - * - * @param int $clientId - * @return \Illuminate\View\View */ public function edit(int $clientId): View { @@ -68,9 +58,6 @@ class ClientsController extends Controller /** * Process the request to edit a client name. - * - * @param int $clientId - * @return \Illuminate\Http\RedirectResponse */ public function update(int $clientId): RedirectResponse { @@ -84,9 +71,6 @@ class ClientsController extends Controller /** * Process a request to delete a client. - * - * @param int $clientId - * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $clientId): RedirectResponse { diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php index c32aa610..eb45320c 100644 --- a/app/Http/Controllers/Admin/ContactsController.php +++ b/app/Http/Controllers/Admin/ContactsController.php @@ -4,20 +4,18 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; -use GuzzleHttp\Client; -use App\Models\Contact; -use Illuminate\View\View; -use Illuminate\Http\Request; use App\Http\Controllers\Controller; +use App\Models\Contact; +use GuzzleHttp\Client; use Illuminate\Filesystem\Filesystem; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Arr; +use Illuminate\View\View; class ContactsController extends Controller { /** * List the currect contacts that can be edited. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -28,8 +26,6 @@ class ContactsController extends Controller /** * Display the form to add a new contact. - * - * @return \Illuminate\View\View */ public function create(): View { @@ -38,12 +34,10 @@ class ContactsController extends Controller /** * Process the request to add a new contact. - * - * @return \Illuminate\Http\RedirectResponse */ public function store(): RedirectResponse { - $contact = new Contact(); + $contact = new Contact; $contact->name = request()->input('name'); $contact->nick = request()->input('nick'); $contact->homepage = request()->input('homepage'); @@ -56,9 +50,6 @@ class ContactsController extends Controller /** * Show the form to edit an existing contact. - * - * @param int $contactId - * @return \Illuminate\View\View */ public function edit(int $contactId): View { @@ -71,9 +62,6 @@ class ContactsController extends Controller * Process the request to edit a contact. * * @todo Allow saving profile pictures for people without homepages - * - * @param int $contactId - * @return \Illuminate\Http\RedirectResponse */ public function update(int $contactId): RedirectResponse { @@ -88,7 +76,7 @@ class ContactsController extends Controller if (request()->hasFile('avatar') && (request()->input('homepage') != '')) { $dir = parse_url(request()->input('homepage'), PHP_URL_HOST); $destination = public_path() . '/assets/profile-images/' . $dir; - $filesystem = new Filesystem(); + $filesystem = new Filesystem; if ($filesystem->isDirectory($destination) === false) { $filesystem->makeDirectory($destination); } @@ -100,9 +88,6 @@ class ContactsController extends Controller /** * Process the request to delete a contact. - * - * @param int $contactId - * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $contactId): RedirectResponse { @@ -118,7 +103,6 @@ class ContactsController extends Controller * This method attempts to find the microformat marked-up profile image * from a given homepage and save it accordingly * - * @param int $contactId * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View */ public function getAvatar(int $contactId) @@ -137,8 +121,8 @@ class ContactsController extends Controller } $mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage); foreach ($mf2['items'] as $microformat) { - if (array_get($microformat, 'type.0') == 'h-card') { - $avatarURL = array_get($microformat, 'properties.photo.0'); + if (Arr::get($microformat, 'type.0') === 'h-card') { + $avatarURL = Arr::get($microformat, 'properties.photo.0.value'); break; } } @@ -152,7 +136,7 @@ class ContactsController extends Controller } if ($avatar !== null) { $directory = public_path() . '/assets/profile-images/' . parse_url($contact->homepage, PHP_URL_HOST); - $filesystem = new Filesystem(); + $filesystem = new Filesystem; if ($filesystem->isDirectory($directory) === false) { $filesystem->makeDirectory($directory); } diff --git a/app/Http/Controllers/Admin/HomeController.php b/app/Http/Controllers/Admin/HomeController.php index 6ad2d0d7..ae4f4d36 100644 --- a/app/Http/Controllers/Admin/HomeController.php +++ b/app/Http/Controllers/Admin/HomeController.php @@ -4,15 +4,13 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; -use Illuminate\View\View; use App\Http\Controllers\Controller; +use Illuminate\View\View; class HomeController extends Controller { /** * Show the homepage of the admin CP. - * - * @return \Illuminate\View\View */ public function welcome(): View { diff --git a/app/Http/Controllers/Admin/LikesController.php b/app/Http/Controllers/Admin/LikesController.php index 6d69a879..9ebd7e74 100644 --- a/app/Http/Controllers/Admin/LikesController.php +++ b/app/Http/Controllers/Admin/LikesController.php @@ -4,18 +4,16 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; -use App\Models\Like; -use App\Jobs\ProcessLike; -use Illuminate\View\View; use App\Http\Controllers\Controller; +use App\Jobs\ProcessLike; +use App\Models\Like; use Illuminate\Http\RedirectResponse; +use Illuminate\View\View; class LikesController extends Controller { /** * List the likes that can be edited. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -26,8 +24,6 @@ class LikesController extends Controller /** * Show the form to make a new like. - * - * @return \Illuminate\View\View */ public function create(): View { @@ -36,8 +32,6 @@ class LikesController extends Controller /** * Process a request to make a new like. - * - * @return \Illuminate\Http\RedirectResponse */ public function store(): RedirectResponse { @@ -51,9 +45,6 @@ class LikesController extends Controller /** * Display the form to edit a specific like. - * - * @param int $likeId - * @return \Illuminate\View\View */ public function edit(int $likeId): View { @@ -67,9 +58,6 @@ class LikesController extends Controller /** * Process a request to edit a like. - * - * @param int $likeId - * @return \Illuminate\Http\RedirectResponse */ public function update(int $likeId): RedirectResponse { @@ -83,9 +71,6 @@ class LikesController extends Controller /** * Process the request to delete a like. - * - * @param int $likeId - * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $likeId): RedirectResponse { diff --git a/app/Http/Controllers/Admin/NotesController.php b/app/Http/Controllers/Admin/NotesController.php index 98ef6f9a..c6ed93ba 100644 --- a/app/Http/Controllers/Admin/NotesController.php +++ b/app/Http/Controllers/Admin/NotesController.php @@ -4,19 +4,17 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; -use App\Models\Note; -use Illuminate\View\View; -use Illuminate\Http\Request; -use App\Jobs\SendWebMentions; use App\Http\Controllers\Controller; +use App\Jobs\SendWebMentions; +use App\Models\Note; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; class NotesController extends Controller { /** * List the notes that can be edited. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -30,8 +28,6 @@ class NotesController extends Controller /** * Show the form to make a new note. - * - * @return \Illuminate\View\View */ public function create(): View { @@ -40,14 +36,12 @@ class NotesController extends Controller /** * Process a request to make a new note. - * - * @return \Illuminate\Http\RedirectResponse */ - public function store(): RedirectResponse + public function store(Request $request): RedirectResponse { Note::create([ - 'in-reply-to' => request()->input('in-reply-to'), - 'note' => request()->input('content'), + 'in_reply_to' => $request->input('in-reply-to'), + 'note' => $request->input('content'), ]); return redirect('/admin/notes'); @@ -55,9 +49,6 @@ class NotesController extends Controller /** * Display the form to edit a specific note. - * - * @param int $noteId - * @return \Illuminate\View\View */ public function edit(int $noteId): View { @@ -70,13 +61,10 @@ class NotesController extends Controller /** * Process a request to edit a note. Easy since this can only be done * from the admin CP. - * - * @param int $noteId - * @return \Illuminate\Http\RedirectResponse */ public function update(int $noteId): RedirectResponse { - //update note data + // update note data $note = Note::findOrFail($noteId); $note->note = request()->input('content'); $note->in_reply_to = request()->input('in-reply-to'); @@ -91,9 +79,6 @@ class NotesController extends Controller /** * Delete the note. - * - * @param int $noteId - * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $noteId): RedirectResponse { diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php new file mode 100644 index 00000000..9f635f10 --- /dev/null +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -0,0 +1,326 @@ +user(); + $passkeys = $user->passkey; + + return view('admin.passkeys.index', compact('passkeys')); + } + + /** + * @throws RandomException + * @throws \JsonException + */ + public function getCreateOptions(Request $request): JsonResponse + { + /** @var User $user */ + $user = auth()->user(); + + // RP Entity i.e. the application + $rpEntity = PublicKeyCredentialRpEntity::create( + name: config('app.name'), + id: config('app.url'), + ); + + // User Entity + $userEntity = PublicKeyCredentialUserEntity::create( + name: $user->name, + id: (string) $user->id, + displayName: $user->name, + ); + + // Challenge + $challenge = random_bytes(16); + + // List of supported public key parameters + $pubKeyCredParams = collect([ + Algorithms::COSE_ALGORITHM_EDDSA, + Algorithms::COSE_ALGORITHM_ES256, + Algorithms::COSE_ALGORITHM_RS256, + ])->map( + fn ($algorithm) => PublicKeyCredentialParameters::create('public-key', $algorithm) + )->toArray(); + + $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create( + userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED, + residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED, + ); + + $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::create( + rp: $rpEntity, + user: $userEntity, + challenge: $challenge, + pubKeyCredParams: $pubKeyCredParams, + authenticatorSelection: $authenticatorSelectionCriteria, + attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE + ); + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + $webauthnSerializerFactory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $webauthnSerializer = $webauthnSerializerFactory->create(); + $publicKeyCredentialCreationOptions = $webauthnSerializer->serialize( + data: $publicKeyCredentialCreationOptions, + format: 'json' + ); + + $request->session()->put('create_options', $publicKeyCredentialCreationOptions); + + return JsonResponse::fromJsonString($publicKeyCredentialCreationOptions); + } + + /** + * @throws Throwable + * @throws WebauthnException + * @throws \JsonException + */ + public function create(Request $request): JsonResponse + { + /** @var User $user */ + $user = auth()->user(); + + $publicKeyCredentialCreationOptionsData = session('create_options'); + // 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'); + } + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + $webauthnSerializerFactory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $webauthnSerializer = $webauthnSerializerFactory->create(); + + $publicKeyCredential = $webauthnSerializer->deserialize( + json_encode($request->all(), JSON_THROW_ON_ERROR), + PublicKeyCredential::class, + 'json' + ); + + if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) { + throw new WebAuthnException('Invalid response type'); + } + + $algorithmManager = new Manager; + $algorithmManager->add(new Ed25519); + $algorithmManager->add(new ES256); + $algorithmManager->add(new RS256); + + $ceremonyStepManagerFactory = new CeremonyStepManagerFactory; + $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); + $ceremonyStepManagerFactory->setAttestationStatementSupportManager( + $attestationStatementSupportManager + ); + $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( + ExtensionOutputCheckerHandler::create() + ); + $allowedOrigins = []; + if (App::environment('local', 'development')) { + $allowedOrigins = [config('app.url')]; + } + $ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins); + + $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( + ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony() + ); + + $publicKeyCredentialCreationOptions = $webauthnSerializer->deserialize( + $publicKeyCredentialCreationOptionsData, + PublicKeyCredentialCreationOptions::class, + 'json' + ); + + $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( + authenticatorAttestationResponse: $publicKeyCredential->response, + publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions, + host: config('app.url') + ); + + $user->passkey()->create([ + 'passkey_id' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->publicKeyCredentialId), + 'passkey' => json_encode($publicKeyCredentialSource, JSON_THROW_ON_ERROR), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Passkey created successfully', + ]); + } + + /** + * @throws RandomException + * @throws \JsonException + */ + public function getRequestOptions(Request $request): JsonResponse + { + $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create( + challenge: random_bytes(16), + userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED + ); + + $attestationStatementSupportManager = AttestationStatementSupportManager::create(); + $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create()); + $factory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $serializer = $factory->create(); + $publicKeyCredentialRequestOptions = $serializer->serialize(data: $publicKeyCredentialRequestOptions, format: 'json'); + + $request->session()->put('request_options', $publicKeyCredentialRequestOptions); + + return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions); + } + + /** + * @throws \JsonException + */ + public function login(Request $request): JsonResponse + { + $requestOptions = session('request_options'); + $request->session()->forget('request_options'); + + if (empty($requestOptions)) { + return response()->json([ + 'success' => false, + 'message' => 'No request options found', + ], 400); + } + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + + $webauthnSerializerFactory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $webauthnSerializer = $webauthnSerializerFactory->create(); + + $publicKeyCredential = $webauthnSerializer->deserialize( + json_encode($request->all(), JSON_THROW_ON_ERROR), + PublicKeyCredential::class, + 'json' + ); + + if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid response type', + ], 400); + } + + $passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id); + if (! $passkey) { + return response()->json([ + 'success' => false, + 'message' => 'Passkey not found', + ], 404); + } + + $publicKeyCredentialSource = $webauthnSerializer->deserialize( + $passkey->passkey, + PublicKeyCredentialSource::class, + 'json' + ); + + $algorithmManager = new Manager; + $algorithmManager->add(new Ed25519); + $algorithmManager->add(new ES256); + $algorithmManager->add(new RS256); + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + + $ceremonyStepManagerFactory = new CeremonyStepManagerFactory; + $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); + $ceremonyStepManagerFactory->setAttestationStatementSupportManager( + $attestationStatementSupportManager + ); + $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( + ExtensionOutputCheckerHandler::create() + ); + $allowedOrigins = []; + if (App::environment('local', 'development')) { + $allowedOrigins = [config('app.url')]; + } + $ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins); + + $authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create( + ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony() + ); + + $publicKeyCredentialRequestOptions = $webauthnSerializer->deserialize( + $requestOptions, + PublicKeyCredentialRequestOptions::class, + 'json' + ); + + try { + $authenticatorAssertionResponseValidator->check( + publicKeyCredentialSource: $publicKeyCredentialSource, + authenticatorAssertionResponse: $publicKeyCredential->response, + publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions, + host: config('app.url'), + userHandle: null, + ); + } catch (Throwable) { + return response()->json([ + 'success' => false, + 'message' => 'Passkey could not be verified', + ], 500); + } + + $user = User::find($passkey->user_id); + Auth::login($user); + + return response()->json([ + 'success' => true, + 'message' => 'Passkey verified successfully', + ]); + } +} diff --git a/app/Http/Controllers/Admin/PlacesController.php b/app/Http/Controllers/Admin/PlacesController.php index 9247eb31..e5e82bcd 100644 --- a/app/Http/Controllers/Admin/PlacesController.php +++ b/app/Http/Controllers/Admin/PlacesController.php @@ -4,17 +4,15 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; -use App\Models\Place; -use Illuminate\View\View; -use Illuminate\Http\Request; -use App\Services\PlaceService; use App\Http\Controllers\Controller; +use App\Models\Place; +use App\Services\PlaceService; use Illuminate\Http\RedirectResponse; -use Phaza\LaravelPostgis\Geometries\Point; +use Illuminate\View\View; class PlacesController extends Controller { - protected $placeService; + protected PlaceService $placeService; public function __construct(PlaceService $placeService) { @@ -23,8 +21,6 @@ class PlacesController extends Controller /** * List the places that can be edited. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -35,8 +31,6 @@ class PlacesController extends Controller /** * Show the form to make a new place. - * - * @return \Illuminate\View\View */ public function create(): View { @@ -45,22 +39,23 @@ class PlacesController extends Controller /** * Process a request to make a new place. - * - * @return \Illuminate\Http\RedirectResponse */ public function store(): RedirectResponse { - $data = request()->only(['name', 'description', 'latitude', 'longitude']); - $place = $this->placeService->createPlace($data); + $this->placeService->createPlace( + request()->only([ + 'name', + 'description', + 'latitude', + 'longitude', + ]) + ); return redirect('/admin/places'); } /** * Display the form to edit a specific place. - * - * @param int $placeId - * @return \Illuminate\View\View */ public function edit(int $placeId): View { @@ -71,19 +66,14 @@ class PlacesController extends Controller /** * Process a request to edit a place. - * - * @param int $placeId - * @return \Illuminate\Http\RedirectResponse */ public function update(int $placeId): RedirectResponse { $place = Place::findOrFail($placeId); $place->name = request()->input('name'); $place->description = request()->input('description'); - $place->location = new Point( - (float) request()->input('latitude'), - (float) request()->input('longitude') - ); + $place->latitude = request()->input('latitude'); + $place->longitude = request()->input('longitude'); $place->icon = request()->input('icon'); $place->save(); @@ -92,14 +82,11 @@ class PlacesController extends Controller /** * List the places we can merge with the current place. - * - * @param int $placeId - * @return \Illuminate\View\View */ public function mergeIndex(int $placeId): View { $first = Place::find($placeId); - $results = Place::near(new Point($first->latitude, $first->longitude))->get(); + $results = Place::near((object) ['latitude' => $first->latitude, 'longitude' => $first->longitude])->get(); $places = []; foreach ($results as $place) { if ($place->slug !== $first->slug) { @@ -112,10 +99,6 @@ class PlacesController extends Controller /** * Show a form for merging two specific places. - * - * @param int $placeId1 - * @param int $placeId2 - * @return \Illuminate\View\View */ public function mergeEdit(int $placeId1, int $placeId2): View { @@ -127,8 +110,6 @@ class PlacesController extends Controller /** * Process the request to merge two places. - * - * @return \Illuminate\Http\RedirectResponse */ public function mergeStore(): RedirectResponse { diff --git a/app/Http/Controllers/Admin/SyndicationTargetsController.php b/app/Http/Controllers/Admin/SyndicationTargetsController.php new file mode 100644 index 00000000..dc14a2d2 --- /dev/null +++ b/app/Http/Controllers/Admin/SyndicationTargetsController.php @@ -0,0 +1,94 @@ +validate([ + 'uid' => 'required|string', + 'name' => 'required|string', + 'service_name' => 'nullable|string', + 'service_url' => 'nullable|string', + 'service_photo' => 'nullable|string', + 'user_name' => 'nullable|string', + 'user_url' => 'nullable|string', + 'user_photo' => 'nullable|string', + ]); + + SyndicationTarget::create($validated); + + return redirect('/admin/syndication'); + } + + /** + * Show a form to edit a syndication target. + */ + public function edit(SyndicationTarget $syndicationTarget): View + { + return view('admin.syndication.edit', [ + 'syndication_target' => $syndicationTarget, + ]); + } + + /** + * Process the request to edit a client name. + */ + public function update(Request $request, SyndicationTarget $syndicationTarget): RedirectResponse + { + $validated = $request->validate([ + 'uid' => 'required|string', + 'name' => 'required|string', + 'service_name' => 'nullable|string', + 'service_url' => 'nullable|string', + 'service_photo' => 'nullable|string', + 'user_name' => 'nullable|string', + 'user_url' => 'nullable|string', + 'user_photo' => 'nullable|string', + ]); + + $syndicationTarget->update($validated); + + return redirect('/admin/syndication'); + } + + /** + * Process a request to delete a client. + */ + public function destroy(SyndicationTarget $syndicationTarget): RedirectResponse + { + $syndicationTarget->delete(); + + return redirect('/admin/syndication'); + } +} diff --git a/app/Http/Controllers/ArticlesController.php b/app/Http/Controllers/ArticlesController.php index cdb91b61..9ab860d7 100644 --- a/app/Http/Controllers/ArticlesController.php +++ b/app/Http/Controllers/ArticlesController.php @@ -5,60 +5,54 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Article; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use Jonnybarnes\IndieWeb\Numbers; -use Illuminate\Http\RedirectResponse; class ArticlesController extends Controller { /** * Show all articles (with pagination). - * - * @param int $year - * @param int $month - * @return \Illuminate\View\View */ - public function index(int $year = null, int $month = null): View + public function index(?int $year = null, ?int $month = null): View { $articles = Article::where('published', '1') - ->date($year, $month) - ->orderBy('updated_at', 'desc') - ->simplePaginate(5); + ->date($year, $month) + ->orderBy('updated_at', 'desc') + ->simplePaginate(5); return view('articles.index', compact('articles')); } /** * Show a single article. - * - * @param int $year - * @param int $month - * @param string $slug - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View */ - public function show(int $year, int $month, string $slug) + public function show(int $year, int $month, string $slug): RedirectResponse|View { - $article = Article::where('titleurl', $slug)->firstOrFail(); + try { + $article = Article::where('titleurl', $slug)->firstOrFail(); + } catch (ModelNotFoundException $exception) { + abort(404); + } + if ($article->updated_at->year != $year || $article->updated_at->month != $month) { return redirect('/blog/' . $article->updated_at->year . '/' . $article->updated_at->format('m') - .'/' . $slug); + . '/' . $slug); } return view('articles.show', compact('article')); } /** - * We only have the ID, work out post title, year and month - * and redirect to it. - * - * @param int $idFromUrl - * @return \Illuminte\Http\RedirectResponse + * We only have the ID, work out post title, year and month and redirect to it. */ - public function onlyIdInUrl(int $idFromUrl): RedirectResponse + public function onlyIdInUrl(string $idFromUrl): RedirectResponse { $realId = resolve(Numbers::class)->b60tonum($idFromUrl); + $article = Article::findOrFail($realId); return redirect($article->link); diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index b2793be4..bd0022d6 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -4,39 +4,59 @@ declare(strict_types=1); namespace App\Http\Controllers; -use Illuminate\View\View; -use Illuminate\Http\Request; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\View\View; class AuthController extends Controller { /** * Show the login form. - * - * @return \Illuminate\View\View */ - public function showLogin(): View + public function showLogin(): View|RedirectResponse { + if (Auth::check()) { + return redirect('/'); + } + return view('login'); } /** - * Log in a user, set a sesion variable, check credentials against - * the .env file. - * - * @return \Illuminate\Http\RedirectResponse + * Log in a user, set a session variable, check credentials against the `.env` file. */ - public function login(): RedirectResponse + public function login(Request $request): RedirectResponse { - if (request()->input('username') === config('admin.user') - && - request()->input('password') === config('admin.pass') - ) { - session(['loggedin' => true]); + $credentials = $request->only('name', 'password'); - return redirect()->intended('admin'); + if (Auth::attempt($credentials, true)) { + return redirect()->intended('/admin'); } return redirect()->route('login'); } + + /** + * Show the form to allow a user to log-out. + */ + public function showLogout(): View|RedirectResponse + { + if (Auth::check() === false) { + // The user is not logged in, just redirect them home + return redirect('/'); + } + + return view('logout'); + } + + /** + * Log the user out from their current session. + */ + public function logout(): RedirectResponse + { + Auth::logout(); + + return redirect('/'); + } } diff --git a/app/Http/Controllers/BookmarksController.php b/app/Http/Controllers/BookmarksController.php index 8bd95d96..b4bb3c13 100644 --- a/app/Http/Controllers/BookmarksController.php +++ b/app/Http/Controllers/BookmarksController.php @@ -11,8 +11,6 @@ class BookmarksController extends Controller { /** * Show the most recent bookmarks. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -23,9 +21,6 @@ class BookmarksController extends Controller /** * Show a single bookmark. - * - * @param \App\Models\Bookmark $bookmark - * @return \Illuminate\View\View */ public function show(Bookmark $bookmark): View { @@ -33,4 +28,16 @@ class BookmarksController extends Controller return view('bookmarks.show', compact('bookmark')); } + + /** + * Show bookmarks tagged with a specific tag. + */ + public function tagged(string $tag): View + { + $bookmarks = Bookmark::whereHas('tags', function ($query) use ($tag) { + $query->where('tag', $tag); + })->latest()->with('tags')->withCount('tags')->paginate(10); + + return view('bookmarks.tagged', compact('bookmarks', 'tag')); + } } diff --git a/app/Http/Controllers/ContactsController.php b/app/Http/Controllers/ContactsController.php index 5ed7f945..280cc3ed 100644 --- a/app/Http/Controllers/ContactsController.php +++ b/app/Http/Controllers/ContactsController.php @@ -5,19 +5,17 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Contact; -use Illuminate\View\View; use Illuminate\Filesystem\Filesystem; +use Illuminate\View\View; class ContactsController extends Controller { /** * Show all the contacts. - * - * @return \Illuminate\View\View */ public function index(): View { - $filesystem = new Filesystem(); + $filesystem = new Filesystem; $contacts = Contact::all(); foreach ($contacts as $contact) { $contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST); @@ -33,18 +31,13 @@ class ContactsController extends Controller /** * Show a single contact. - * - * @todo Use implicit model binding. - * - * @param string $nick The nickname associated with contact - * @return \Illuminate\View\View */ - public function show(string $nick): View + public function show(Contact $contact): View { - $filesystem = new Filesystem(); - $contact = Contact::where('nick', '=', $nick)->firstOrFail(); $contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST); $file = public_path() . '/assets/profile-images/' . $contact->homepageHost . '/image'; + + $filesystem = new Filesystem; $image = ($filesystem->exists($file)) ? '/assets/profile-images/' . $contact->homepageHost . '/image' : diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 03e02a23..8677cd5c 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,12 +2,7 @@ namespace App\Http\Controllers; -use Illuminate\Foundation\Bus\DispatchesJobs; -use Illuminate\Routing\Controller as BaseController; -use Illuminate\Foundation\Validation\ValidatesRequests; -use Illuminate\Foundation\Auth\Access\AuthorizesRequests; - -class Controller extends BaseController +abstract class Controller { - use AuthorizesRequests, DispatchesJobs, ValidatesRequests; + // } diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index e7602230..eb0847a3 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -4,15 +4,15 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Models\Article; +use App\Models\Note; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; -use App\Models\{Article, Note}; class FeedsController extends Controller { /** * Returns the blog RSS feed. - * - * @return \Illuminate\Http\Response */ public function blogRss(): Response { @@ -20,28 +20,24 @@ class FeedsController extends Controller $buildDate = $articles->first()->updated_at->toRssString(); return response() - ->view('articles.rss', compact('articles', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->view('articles.rss', compact('articles', 'buildDate')) + ->header('Content-Type', 'application/rss+xml; charset=utf-8'); } /** * Returns the blog Atom feed. - * - * @return \Illuminate\Http\Response */ public function blogAtom(): Response { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); return response() - ->view('articles.atom', compact('articles')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); + ->view('articles.atom', compact('articles')) + ->header('Content-Type', 'application/atom+xml; charset=utf-8'); } /** * Returns the notes RSS feed. - * - * @return \Illuminate\Http\Response */ public function notesRss(): Response { @@ -49,39 +45,41 @@ class FeedsController extends Controller $buildDate = $notes->first()->updated_at->toRssString(); return response() - ->view('notes.rss', compact('notes', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->view('notes.rss', compact('notes', 'buildDate')) + ->header('Content-Type', 'application/rss+xml; charset=utf-8'); } /** * Returns the notes Atom feed. - * - * @return \Illuminate\Http\Response */ public function notesAtom(): Response { $notes = Note::latest()->take(20)->get(); return response() - ->view('notes.atom', compact('notes')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); + ->view('notes.atom', compact('notes')) + ->header('Content-Type', 'application/atom+xml; charset=utf-8'); } /** @todo sort out return type for json responses */ /** * Returns the blog JSON feed. - * - * @return \Illuminate\Http\JsonResponse */ - public function blogJson() + public function blogJson(): array { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1', - 'title' => 'The JSON Feed for ' . config('app.display_name') . '’s blog', + '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', + 'authors' => [ + [ + 'name' => config('user.display_name'), + 'url' => config('app.url'), + ], + ], 'items' => [], ]; @@ -93,9 +91,6 @@ class FeedsController extends Controller 'content_html' => $article->main, 'date_published' => $article->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(), - 'author' => [ - 'name' => config('app.display_name'), - ], ]; } @@ -104,33 +99,109 @@ class FeedsController extends Controller /** * Returns the notes JSON feed. - * - * @return \Illuminate\Http\JsonResponse */ - public function notesJson() + public function notesJson(): array { - $notes = Note::latest()->take(20)->get(); + $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1', - 'title' => 'The JSON Feed for ' . config('app.display_name') . '’s notes', + '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', + 'authors' => [ + [ + 'name' => config('user.display_name'), + 'url' => config('app.url'), + ], + ], 'items' => [], ]; foreach ($notes as $key => $note) { $data['items'][$key] = [ - 'id' => $note->longurl, - 'url' => $note->longurl, - 'content_html' => $note->content, + 'id' => $note->uri, + 'url' => $note->uri, + 'content_text' => $note->content, 'date_published' => $note->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $note->updated_at->tz('UTC')->toRfc3339String(), - 'author' => [ - 'name' => config('app.display_name'), - ], ]; + if ($note->tags->count() > 0) { + $data['items'][$key]['tags'] = implode(',', $note->tags->pluck('tag')->toArray()); + } } return $data; } + + /** + * Returns the blog JF2 feed. + */ + public function blogJf2(): JsonResponse + { + $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); + $items = []; + foreach ($articles as $article) { + $items[] = [ + 'type' => 'entry', + 'published' => $article->created_at, + 'uid' => config('app.url') . $article->link, + 'url' => config('app.url') . $article->link, + 'content' => [ + 'text' => $article->main, + 'html' => $article->html, + ], + 'post-type' => 'article', + ]; + } + + return response()->json([ + 'type' => 'feed', + 'name' => 'Blog feed for ' . config('app.name'), + 'url' => url('/blog'), + 'author' => [ + 'type' => 'card', + 'name' => config('user.display_name'), + 'url' => config('app.url'), + ], + 'children' => $items, + ], 200, [ + 'Content-Type' => 'application/jf2feed+json', + ]); + } + + /** + * Returns the notes JF2 feed. + */ + public function notesJf2(): JsonResponse + { + $notes = Note::latest()->take(20)->get(); + $items = []; + foreach ($notes as $note) { + $items[] = [ + 'type' => 'entry', + 'published' => $note->created_at, + 'uid' => $note->uri, + 'url' => $note->uri, + 'content' => [ + 'text' => $note->getRawOriginal('note'), + 'html' => $note->note, + ], + 'post-type' => 'note', + ]; + } + + return response()->json([ + 'type' => 'feed', + 'name' => 'Notes feed for ' . config('app.name'), + 'url' => url('/notes'), + 'author' => [ + 'type' => 'card', + 'name' => config('user.display_name'), + 'url' => config('app.url'), + ], + 'children' => $items, + ], 200, [ + 'Content-Type' => 'application/jf2feed+json', + ]); + } } diff --git a/app/Http/Controllers/FrontPageController.php b/app/Http/Controllers/FrontPageController.php new file mode 100644 index 00000000..19537663 --- /dev/null +++ b/app/Http/Controllers/FrontPageController.php @@ -0,0 +1,47 @@ +with(['media', 'client', 'place'])->withCount(['webmentions AS replies' => function ($query) { + $query->where('type', 'in-reply-to'); + }]) + ->withCount(['webmentions AS likes' => function ($query) { + $query->where('type', 'like-of'); + }]) + ->withCount(['webmentions AS reposts' => function ($query) { + $query->where('type', 'repost-of'); + }])->get(); + $articles = Article::latest()->get(); + $bookmarks = Bookmark::latest()->with('tags')->get(); + $likes = Like::latest()->get(); + + $items = collect($notes) + ->merge($articles) + ->merge($bookmarks) + ->merge($likes) + ->sortByDesc('updated_at') + ->paginate(10); + + $bio = Bio::first()?->content; + + return view('front-page', [ + 'items' => $items, + 'bio' => $bio, + ]); + } +} diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php new file mode 100644 index 00000000..45b488da --- /dev/null +++ b/app/Http/Controllers/IndieAuthController.php @@ -0,0 +1,327 @@ +json([ + 'issuer' => config('app.url'), + 'authorization_endpoint' => route('indieauth.start'), + 'token_endpoint' => route('indieauth.token'), + 'code_challenge_methods_supported' => ['S256'], + // 'introspection_endpoint' => route('indieauth.introspection'), + // 'introspection_endpoint_auth_methods_supported' => ['none'], + ]); + } + + /** + * Process a GET request to the IndieAuth endpoint. + * + * This is the first step in the IndieAuth flow, where the client app sends the user to the IndieAuth endpoint. + */ + public function start(Request $request): View + { + // First check all required params are present + $validator = Validator::make($request->all(), [ + 'response_type' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'state' => 'required', + 'code_challenge' => 'required:string', + 'code_challenge_method' => 'required:string', + ], [ + 'response_type' => 'response_type is required', + 'client_id.required' => 'client_id is required to display which app is asking for authentication', + 'redirect_uri.required' => 'redirect_uri is required so we can progress successful requests', + 'state.required' => 'state is required', + 'code_challenge.required' => 'code_challenge is required', + 'code_challenge_method.required' => 'code_challenge_method is required', + ]); + + if ($validator->fails()) { + return view('indieauth.error')->withErrors($validator); + } + + if ($request->get('response_type') !== 'code') { + return view('indieauth.error')->withErrors(['response_type' => 'only a response_type of "code" is supported']); + } + + if (mb_strtoupper($request->get('code_challenge_method')) !== 'S256') { + return view('indieauth.error')->withErrors(['code_challenge_method' => 'only a code_challenge_method of "S256" is supported']); + } + + if (! $this->isValidRedirectUri($request->get('client_id'), $request->get('redirect_uri'))) { + return view('indieauth.error')->withErrors(['redirect_uri' => 'redirect_uri is not valid for this client_id']); + } + + $scopes = $request->get('scope', ''); + $scopes = explode(' ', $scopes); + + return view('indieauth.start', [ + 'me' => $request->get('me'), + 'client_id' => $request->get('client_id'), + 'redirect_uri' => $request->get('redirect_uri'), + 'state' => $request->get('state'), + 'scopes' => $scopes, + 'code_challenge' => $request->get('code_challenge'), + 'code_challenge_method' => $request->get('code_challenge_method'), + ]); + } + + /** + * Confirm an IndieAuth approval request. + * + * Generates an auth code and redirects the user back to the client app. + * + * @throws RandomException + */ + public function confirm(Request $request): RedirectResponse + { + $authCode = bin2hex(random_bytes(16)); + + $cacheKey = hash('xxh3', $request->get('client_id')); + + $indieAuthRequestData = [ + 'code_challenge' => $request->get('code_challenge'), + 'code_challenge_method' => $request->get('code_challenge_method'), + 'client_id' => $request->get('client_id'), + 'redirect_uri' => $request->get('redirect_uri'), + 'auth_code' => $authCode, + 'scope' => implode(' ', $request->get('scope', '')), + ]; + + Cache::put($cacheKey, $indieAuthRequestData, now()->addMinutes(10)); + + $redirectUri = new Uri($request->get('redirect_uri')); + $redirectUri = Uri::withQueryValues($redirectUri, [ + 'code' => $authCode, + 'state' => $request->get('state'), + 'iss' => config('app.url'), + ]); + + return redirect()->away($redirectUri); + } + + /** + * Process a POST request to the IndieAuth auth endpoint. + * + * This is one possible second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth + * endpoint. As it is to the auth endpoint we return profile information. A similar request can be made to the token + * endpoint to get an access token. + */ + public function processCodeExchange(Request $request): JsonResponse + { + $invalidCodeResponse = $this->validateAuthorizationCode($request); + + if ($invalidCodeResponse instanceof JsonResponse) { + return $invalidCodeResponse; + } + + return response()->json([ + 'me' => config('app.url'), + ]); + } + + /** + * Process a POST request to the IndieAuth token endpoint. + * + * This is another possible second step in the IndieAuth flow, where the client app sends the auth code to the + * IndieAuth token endpoint. As it is to the token endpoint we return an access token. + * + * @throws SodiumException + */ + public function processTokenRequest(Request $request): JsonResponse + { + $indieAuthData = $this->validateAuthorizationCode($request); + + if ($indieAuthData instanceof JsonResponse) { + return $indieAuthData; + } + + if ($indieAuthData['scope'] === '') { + return response()->json(['errors' => [ + 'scope' => [ + 'The scope property must be non-empty for an access token to be issued.', + ], + ]], 400); + } + + $tokenData = [ + 'me' => config('app.url'), + 'client_id' => $request->get('client_id'), + 'scope' => $indieAuthData['scope'], + ]; + $tokenService = resolve(TokenService::class); + $token = $tokenService->getNewToken($tokenData); + + return response()->json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'scope' => $indieAuthData['scope'], + 'me' => config('app.url'), + ]); + } + + protected function isValidRedirectUri(string $clientId, string $redirectUri): bool + { + // If client_id is not a valid URL, then it's not valid + $clientIdParsed = \Mf2\parseUriToComponents($clientId); + if (! isset($clientIdParsed['authority'])) { + return false; + } + + // If redirect_uri is not a valid URL, then it's not valid + $redirectUriParsed = \Mf2\parseUriToComponents($redirectUri); + if (! isset($redirectUriParsed['authority'])) { + return false; + } + + // If client_id and redirect_uri are the same host, then it's valid + if ($clientIdParsed['authority'] === $redirectUriParsed['authority']) { + return true; + } + + // 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) { + return false; + } + + $clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId); + + $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; + + return in_array($redirectUri, $redirectUris, true); + } + + /** + * @throws SodiumException + */ + protected function validateAuthorizationCode(Request $request): JsonResponse|array + { + // First check all the data is present + $validator = Validator::make($request->all(), [ + 'grant_type' => 'required:string', + 'code' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'code_verifier' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 400); + } + + if ($request->get('grant_type') !== 'authorization_code') { + return response()->json(['errors' => [ + 'grant_type' => [ + 'Only a grant type of "authorization_code" is supported.', + ], + ]], 400); + } + + // Check cache for auth code + $cacheKey = hash('xxh3', $request->get('client_id')); + $indieAuthRequestData = Cache::pull($cacheKey); + + if ($indieAuthRequestData === null) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 404); + } + + // Check the IndieAuth code + if (! array_key_exists('auth_code', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + + // Check code verifier + if (! array_key_exists('code_challenge', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + if (! hash_equals( + $indieAuthRequestData['code_challenge'], + sodium_bin2base64( + hash('sha256', $request->get('code_verifier'), true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ) + )) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + + // Check redirect_uri + if (! array_key_exists('redirect_uri', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['redirect_uri'] !== $request->get('redirect_uri')) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + + // Check client_id + if (! array_key_exists('client_id', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['client_id'] !== $request->get('client_id')) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + + return $indieAuthRequestData; + } +} diff --git a/app/Http/Controllers/LikesController.php b/app/Http/Controllers/LikesController.php index dbc92ec6..af1c483c 100644 --- a/app/Http/Controllers/LikesController.php +++ b/app/Http/Controllers/LikesController.php @@ -11,8 +11,6 @@ class LikesController extends Controller { /** * Show the latest likes. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -23,9 +21,6 @@ class LikesController extends Controller /** * Show a single like. - * - * @param \App\Models\Like $like - * @return \Illuminate\View\View */ public function show(Like $like): View { diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 69e45e35..758b3255 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -4,138 +4,109 @@ declare(strict_types=1); namespace App\Http\Controllers; -use Monolog\Logger; -use Ramsey\Uuid\Uuid; -use App\Jobs\ProcessMedia; -use App\Services\TokenService; +use App\Exceptions\InvalidTokenScopeException; +use App\Exceptions\MicropubHandlerException; +use App\Http\Requests\MicropubRequest; +use App\Models\Place; +use App\Models\SyndicationTarget; +use App\Services\Micropub\MicropubHandlerRegistry; use Illuminate\Http\JsonResponse; -use Illuminate\Http\UploadedFile; -use Monolog\Handler\StreamHandler; -use Intervention\Image\ImageManager; -use Illuminate\Support\Facades\Storage; -use Illuminate\Http\{Request, Response}; -use App\Exceptions\InvalidTokenException; -use App\Models\{Like, Media, Note, Place}; -use Phaza\LaravelPostgis\Geometries\Point; -use Intervention\Image\Exception\NotReadableException; -use App\Services\Micropub\{HCardService, HEntryService, UpdateService}; +use Illuminate\Http\Request; +use Lcobucci\JWT\Token; class MicropubController extends Controller { - protected $tokenService; - protected $hentryService; - protected $hcardService; - protected $updateService; + protected MicropubHandlerRegistry $handlerRegistry; - public function __construct( - TokenService $tokenService, - HEntryService $hentryService, - HCardService $hcardService, - UpdateService $updateService - ) { - $this->tokenService = $tokenService; - $this->hentryService = $hentryService; - $this->hcardService = $hcardService; - $this->updateService = $updateService; + public function __construct(MicropubHandlerRegistry $handlerRegistry) + { + $this->handlerRegistry = $handlerRegistry; } /** - * This function receives an API request, verifies the authenticity - * then passes over the info to the relavent Service class. + * Respond to a POST request to the micropub endpoint. * - * @return \Illuminate\Http\JsonResponse + * 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(): JsonResponse + public function post(MicropubRequest $request): JsonResponse { + $type = $request->getType(); + + if (! $type) { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => 'Microformat object type is missing, for example: h-entry or h-card', + ], 400); + } + try { - $tokenData = $this->tokenService->validateToken(request()->input('access_token')); - } catch (InvalidTokenException $e) { - return $this->invalidTokenResponse(); - } - - if ($tokenData->hasClaim('scope') === false) { - return $this->tokenHasNoScopeResponse(); - } - - $this->logMicropubRequest(request()->all()); - - if ((request()->input('h') == 'entry') || (request()->input('type.0') == 'h-entry')) { - if (stristr($tokenData->getClaim('scope'), 'create') === false) { - return $this->insufficientScopeResponse(); - } - $location = $this->hentryService->process(request()->all(), $this->getCLientId()); + $handler = $this->handlerRegistry->getHandler($type); + $result = $handler->handle($request->getMicropubData()); + // Return appropriate response based on the handler result return response()->json([ - 'response' => 'created', - 'location' => $location, - ], 201)->header('Location', $location); - } - - if (request()->input('h') == 'card' || request()->input('type')[0] == 'h-card') { - if (stristr($tokenData->getClaim('scope'), 'create') === false) { - return $this->insufficientScopeResponse(); - } - $location = $this->hcardService->process(request()->all()); - + 'response' => $result['response'], + 'location' => $result['url'] ?? null, + ], 201)->header('Location', $result['url']); + } catch (\InvalidArgumentException $e) { return response()->json([ - 'response' => 'created', - 'location' => $location, - ], 201)->header('Location', $location); + 'error' => 'invalid_request', + 'error_description' => $e->getMessage(), + ], 400); + } catch (MicropubHandlerException) { + return response()->json([ + 'error' => 'Unknown Micropub type', + '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) { + return response()->json([ + 'error' => 'server_error', + 'error_description' => 'An error occurred processing the request', + ], 500); } - - if (request()->input('action') == 'update') { - if (stristr($tokenData->getClaim('scope'), 'update') === false) { - return $this->insufficientScopeResponse(); - } - - return $this->updateService->process(request()->all()); - } - - return response()->json([ - 'response' => 'error', - 'error_description' => 'unsupported_request_type', - ], 500); } /** * 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 wether the token is valid and respond + * token, here we check whether the token is valid and respond * appropriately. Further if the request has the query parameter - * synidicate-to we respond with the known syndication endpoints. - * - * @return \Illuminate\Http\JsonResponse + * syndicate-to we respond with the known syndication endpoints. */ - public function get(): JsonResponse + public function get(Request $request): JsonResponse { - try { - $tokenData = $this->tokenService->validateToken(request()->input('access_token')); - } catch (InvalidTokenException $e) { - return $this->invalidTokenResponse(); - } - - if (request()->input('q') === 'syndicate-to') { + if ($request->input('q') === 'syndicate-to') { return response()->json([ - 'syndicate-to' => config('syndication.targets'), + 'syndicate-to' => SyndicationTarget::all(), ]); } - if (request()->input('q') == 'config') { + if ($request->input('q') === 'config') { return response()->json([ - 'syndicate-to' => config('syndication.targets'), + 'syndicate-to' => SyndicationTarget::all(), 'media-endpoint' => route('media-endpoint'), ]); } - if (request()->has('q') && substr(request()->input('q'), 0, 4) === 'geo:') { + if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) { preg_match_all( - '/([0-9\.\-]+)/', - request()->input('q'), + '/([0-9.\-]+)/', + $request->input('q'), $matches ); - $distance = (count($matches[0]) == 3) ? 100 * $matches[0][2] : 1000; - $places = Place::near(new Point($matches[0][0], $matches[0][1]))->get(); + $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; + $places = Place::near( + (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], + $distance + )->get(); return response()->json([ 'response' => 'places', @@ -143,206 +114,17 @@ class MicropubController extends Controller ]); } - // default response is just to return the token data + // the default response is just to return the token data + /** @var Token $tokenData */ + $tokenData = $request->input('token_data'); + return response()->json([ 'response' => 'token', 'token' => [ - 'me' => $tokenData->getClaim('me'), - 'scope' => $tokenData->getClaim('scope'), - 'client_id' => $tokenData->getClaim('client_id'), + 'me' => $tokenData['me'], + 'scope' => $tokenData['scope'], + 'client_id' => $tokenData['client_id'], ], ]); } - - /** - * Process a media item posted to the media endpoint. - * - * @return Illuminate\Http\JsonResponse - */ - public function media(): JsonResponse - { - try { - $tokenData = $this->tokenService->validateToken(request()->input('access_token')); - } catch (InvalidTokenException $e) { - return $this->invalidTokenResponse(); - } - - if ($tokenData->hasClaim('scope') === false) { - return $this->tokenHasNoScopeResponse(); - } - - if (stristr($tokenData->getClaim('scope'), 'create') === false) { - return $this->insufficientScopeResponse(); - } - - if ((request()->hasFile('file') && request()->file('file')->isValid()) === false) { - return response()->json([ - 'response' => 'error', - 'error' => 'invalid_request', - 'error_description' => 'The uploaded file failed validation', - ], 400); - } - - $this->logMicropubRequest(request()->all()); - - $filename = $this->saveFile(request()->file('file')); - - $manager = resolve(ImageManager::class); - try { - $image = $manager->make(request()->file('file')); - $width = $image->width(); - } catch (NotReadableException $exception) { - // not an image - $width = null; - } - - $media = Media::create([ - 'token' => request()->bearerToken(), - 'path' => 'media/' . $filename, - 'type' => $this->getFileTypeFromMimeType(request()->file('file')->getMimeType()), - 'image_widths' => $width, - ]); - - ProcessMedia::dispatch($filename); - - return response()->json([ - 'response' => 'created', - 'location' => $media->url, - ], 201)->header('Location', $media->url); - } - - /** - * Return the relavent CORS headers to a pre-flight OPTIONS request. - * - * @return \Illuminate\Http\Response - */ - public function mediaOptionsResponse(): Response - { - return response('OK', 200); - } - - /** - * Get the file type from the mimetype of the uploaded file. - * - * @param string $mimetype - * @return string - */ - private function getFileTypeFromMimeType(string $mimetype): string - { - //try known images - $imageMimeTypes = [ - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/svg+xml', - 'image/tiff', - 'image/webp', - ]; - if (in_array($mimetype, $imageMimeTypes)) { - return 'image'; - } - //try known video - $videoMimeTypes = [ - 'video/mp4', - 'video/mpeg', - 'video/ogg', - 'video/quicktime', - 'video/webm', - ]; - if (in_array($mimetype, $videoMimeTypes)) { - return 'video'; - } - //try known audio types - $audioMimeTypes = [ - 'audio/midi', - 'audio/mpeg', - 'audio/ogg', - 'audio/x-m4a', - ]; - if (in_array($mimetype, $audioMimeTypes)) { - return 'audio'; - } - - return 'download'; - } - - /** - * Determine the client id from the access token sent with the request. - * - * @return string - */ - private function getClientId(): string - { - return resolve(TokenService::class) - ->validateToken(request()->input('access_token')) - ->getClaim('client_id'); - } - - /** - * Save the details of the micropub request to a log file. - * - * @param array $request This is the info from request()->all() - */ - private function logMicropubRequest(array $request) - { - $logger = new Logger('micropub'); - $logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')), Logger::DEBUG); - $logger->debug('MicropubLog', $request); - } - - /** - * Save an uploaded file to the local disk. - * - * @param \Illuminate\Http\UploadedFele $file - * @return string $filename - */ - private function saveFile(UploadedFile $file): string - { - $filename = Uuid::uuid4() . '.' . $file->extension(); - Storage::disk('local')->putFileAs('', $file, $filename); - - return $filename; - } - - /** - * Generate a response to be returned when the token has insufficient scope. - * - * @return \Illuminate\Http\JsonRepsonse - */ - private function insufficientScopeResponse() - { - return response()->json([ - 'response' => 'error', - 'error' => 'insufficient_scope', - 'error_description' => 'The token’s scope does not have the necessary requirements.', - ], 401); - } - - /** - * Generate a response to be returned when the token is invalid. - * - * @return \Illuminate\Http\JsonRepsonse - */ - private function invalidTokenResponse() - { - return response()->json([ - 'response' => 'error', - 'error' => 'invalid_token', - 'error_description' => 'The provided token did not pass validation', - ], 400); - } - - /** - * Generate a response to be returned when the token has no scope. - * - * @return \Illuminate\Http\JsonRepsonse - */ - private function tokenHasNoScopeResponse() - { - return response()->json([ - 'response' => 'error', - 'error' => 'invalid_request', - 'error_description' => 'The provided token has no scopes', - ], 400); - } } diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php new file mode 100644 index 00000000..fc804ea2 --- /dev/null +++ b/app/Http/Controllers/MicropubMediaController.php @@ -0,0 +1,201 @@ +input('token_data'); + + $scopes = $tokenData['scope']; + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } + if (! in_array('create', $scopes, true)) { + return (new MicropubResponses)->insufficientScopeResponse(); + } + + if ($request->input('q') === 'last') { + $media = Media::where('created_at', '>=', Carbon::now()->subMinutes(30)) + ->where('token', $request->input('access_token')) + ->latest() + ->first(); + $mediaUrl = $media?->url; + + return response()->json(['url' => $mediaUrl]); + } + + if ($request->input('q') === 'source') { + $limit = $request->input('limit', 10); + $offset = $request->input('offset', 0); + + $media = Media::latest()->offset($offset)->limit($limit)->get(); + + $media->transform(function ($mediaItem) { + return [ + 'url' => $mediaItem->url, + 'published' => $mediaItem->created_at->toW3cString(), + 'mime_type' => $mediaItem->mimetype, + ]; + }); + + return response()->json(['items' => $media]); + } + + if ($request->has('q')) { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => sprintf( + 'This server does not know how to handle this q parameter (%s)', + $request->input('q') + ), + ], 400); + } + + return response()->json(['status' => 'OK']); + } + + /** + * Process a media item posted to the media endpoint. + * + * @throws BindingResolutionException + * @throws Exception + */ + public function media(Request $request): JsonResponse + { + $tokenData = $request->input('token_data'); + + $scopes = $tokenData['scope']; + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } + if (! in_array('create', $scopes, true)) { + return (new MicropubResponses)->insufficientScopeResponse(); + } + + if ($request->hasFile('file') === false) { + return response()->json([ + 'response' => 'error', + 'error' => 'invalid_request', + 'error_description' => 'No file was sent with the request', + ], 400); + } + + /** @var UploadedFile $file */ + $file = $request->file('file'); + + if ($file->isValid() === false) { + return response()->json([ + 'response' => 'error', + 'error' => 'invalid_request', + 'error_description' => 'The uploaded file failed validation', + ], 400); + } + + $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) { + // not an image + $width = null; + } + + $media = Media::create([ + 'token' => $request->input('access_token'), + 'path' => $filename, + 'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()), + 'image_widths' => $width, + ]); + + ProcessMedia::dispatch($filename); + + return response()->json([ + 'response' => 'created', + 'location' => $media->url, + ], 201)->header('Location', $media->url); + } + + /** + * Return the relevant CORS headers to a pre-flight OPTIONS request. + */ + public function mediaOptionsResponse(): Response + { + return response('OK', 200); + } + + /** + * Get the file type from the mime-type of the uploaded file. + */ + private function getFileTypeFromMimeType(string $mimeType): string + { + // try known images + $imageMimeTypes = [ + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/tiff', + 'image/webp', + ]; + if (in_array($mimeType, $imageMimeTypes)) { + return 'image'; + } + // try known video + $videoMimeTypes = [ + 'video/mp4', + 'video/mpeg', + 'video/ogg', + 'video/quicktime', + 'video/webm', + ]; + if (in_array($mimeType, $videoMimeTypes)) { + return 'video'; + } + // try known audio types + $audioMimeTypes = [ + 'audio/midi', + 'audio/mpeg', + 'audio/ogg', + 'audio/x-m4a', + ]; + if (in_array($mimeType, $audioMimeTypes)) { + return 'audio'; + } + + return 'download'; + } + + /** + * Save an uploaded file to the local disk. + * + * @throws Exception + */ + private function saveFileToLocal(UploadedFile $file): string + { + $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 d5ff483e..d5c9bc90 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -5,31 +5,33 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Note; -use Illuminate\View\View; -use Illuminate\Http\Request; -use Jonnybarnes\IndieWeb\Numbers; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; -use App\Services\ActivityStreamsService; - -// Need to sort out Twitter and webmentions! +use Illuminate\Http\Response; +use Illuminate\View\View; +use Jonnybarnes\IndieWeb\Numbers; +/** + * @todo Need to sort out Twitter and webmentions! + */ class NotesController extends Controller { /** * Show all the notes. This is also the homepage. - * - * @return \Illuminate\View\View|\Illuminate\Http\JsonResponse */ - public function index() + public function index(): View|Response { - if (request()->wantsActivityStream()) { - return (new ActivityStreamsService)->siteOwnerResponse(); - } - $notes = Note::latest() ->with('place', 'media', 'client') - ->withCount(['webmentions As replies' => function ($query) { + ->withCount(['webmentions AS replies' => function ($query) { $query->where('type', 'in-reply-to'); + }]) + ->withCount(['webmentions AS likes' => function ($query) { + $query->where('type', 'like-of'); + }]) + ->withCount(['webmentions AS reposts' => function ($query) { + $query->where('type', 'repost-of'); }])->paginate(10); return view('notes.index', compact('notes')); @@ -37,16 +39,22 @@ class NotesController extends Controller /** * Show a single note. - * - * @param string $urlId The id of the note - * @return \Illuminate\View\View|\Illuminate\Http\JsonResponse */ - public function show(string $urlId) + public function show(string $urlId): View|JsonResponse|Response { - $note = Note::nb60($urlId)->with('webmentions')->firstOrFail(); - - if (request()->wantsActivityStream()) { - return (new ActivityStreamsService)->singleNoteResponse($note); + try { + $note = Note::nb60($urlId)->with('place', 'media', 'client') + ->withCount(['webmentions AS replies' => function ($query) { + $query->where('type', 'in-reply-to'); + }]) + ->withCount(['webmentions AS likes' => function ($query) { + $query->where('type', 'like-of'); + }]) + ->withCount(['webmentions AS reposts' => function ($query) { + $query->where('type', 'repost-of'); + }])->firstOrFail(); + } catch (ModelNotFoundException $exception) { + abort(404); } return view('notes.show', compact('note')); @@ -54,20 +62,14 @@ class NotesController extends Controller /** * Redirect /note/{decID} to /notes/{nb60id}. - * - * @param int $decId The decimal id of the note - * @return \Illuminate\Http\RedirectResponse */ 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)); } /** * Show all notes tagged with {tag}. - * - * @param string $tag - * @return \Illuminate\View\View */ public function tagged(string $tag): View { @@ -77,4 +79,14 @@ class NotesController extends Controller return view('notes.tagged', compact('notes', 'tag')); } + + /** + * Page to create a new note. + * + * Dummy page for now. + */ + public function create(): View + { + return view('notes.create'); + } } diff --git a/app/Http/Controllers/PlacesController.php b/app/Http/Controllers/PlacesController.php index f93ab259..b949ecde 100644 --- a/app/Http/Controllers/PlacesController.php +++ b/app/Http/Controllers/PlacesController.php @@ -11,8 +11,6 @@ class PlacesController extends Controller { /** * Show all the places. - * - * @return \Illuminate\View\View */ public function index(): View { @@ -23,14 +21,9 @@ class PlacesController extends Controller /** * Show a specific place. - * - * @param string $slug - * @return \Illuminate\View\View */ - public function show(string $slug): View + public function show(Place $place): View { - $place = Place::where('slug', '=', $slug)->firstOrFail(); - return view('singleplace', ['place' => $place]); } } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index b03c16d0..3f366538 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -1,23 +1,34 @@ input('terms'))->paginate(10); + $search = $request->input('q'); - return view('search', compact('notes')); + $notes = Note::search($search) + ->paginate(); + + /** @var Note $note */ + foreach ($notes as $note) { + $note->load('place', 'media', 'client') + ->loadCount(['webmentions AS replies' => function ($query) { + $query->where('type', 'in-reply-to'); + }]) + ->loadCount(['webmentions AS likes' => function ($query) { + $query->where('type', 'like-of'); + }]) + ->loadCount(['webmentions AS reposts' => function ($query) { + $query->where('type', 'repost-of'); + }]); + } + + return view('search', compact('search', 'notes')); } } diff --git a/app/Http/Controllers/SessionStoreController.php b/app/Http/Controllers/SessionStoreController.php deleted file mode 100644 index 042aa958..00000000 --- a/app/Http/Controllers/SessionStoreController.php +++ /dev/null @@ -1,22 +0,0 @@ -input('css'); - - session(['css' => $css]); - - return ['status' => 'ok']; - } -} diff --git a/app/Http/Controllers/ShortURLsController.php b/app/Http/Controllers/ShortURLsController.php deleted file mode 100644 index 2510a7b0..00000000 --- a/app/Http/Controllers/ShortURLsController.php +++ /dev/null @@ -1,69 +0,0 @@ -client = $client; - $this->tokenService = $tokenService; - } - - /** - * If the user has auth’d via the IndieAuth protocol, issue a valid token. - * - * @return \Illuminate\Http\JsonResponse - */ - public function create(): JsonResponse - { - $authorizationEndpoint = $this->client->discoverAuthorizationEndpoint(normalize_url(request()->input('me'))); - if ($authorizationEndpoint) { - $auth = $this->client->verifyIndieAuthCode( - $authorizationEndpoint, - request()->input('code'), - request()->input('me'), - request()->input('redirect_uri'), - request()->input('client_id') - ); - if (array_key_exists('me', $auth)) { - $scope = $auth['scope'] ?? ''; - $tokenData = [ - 'me' => request()->input('me'), - 'client_id' => request()->input('client_id'), - 'scope' => $scope, - ]; - $token = $this->tokenService->getNewToken($tokenData); - $content = [ - 'me' => request()->input('me'), - 'scope' => $scope, - 'access_token' => $token, - ]; - - return response()->json($content); - } - - return response()->json([ - 'error' => 'There was an error verifying the authorisation code.', - ], 401); - } - - return response()->json([ - 'error' => 'Can’t determine the authorisation endpoint.', - ], 400); - } -} diff --git a/app/Http/Controllers/WebMentionsController.php b/app/Http/Controllers/WebMentionsController.php index 960840ea..49eac9b2 100644 --- a/app/Http/Controllers/WebMentionsController.php +++ b/app/Http/Controllers/WebMentionsController.php @@ -4,12 +4,13 @@ declare(strict_types=1); namespace App\Http\Controllers; -use App\Models\Note; -use Illuminate\View\View; -use Illuminate\Http\Response; use App\Jobs\ProcessWebMention; -use Jonnybarnes\IndieWeb\Numbers; +use App\Models\Note; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\View\View; +use Jonnybarnes\IndieWeb\Numbers; class WebMentionsController extends Controller { @@ -18,8 +19,6 @@ class WebMentionsController extends Controller * * This is probably someone looking for information about what * webmentions are, or about my particular implementation. - * - * @return \Illuminate\View\View */ public function get(): View { @@ -28,29 +27,27 @@ class WebMentionsController extends Controller /** * Receive and process a webmention. - * - * @return \Illuminate\Http\Respone */ - public function receive(): Response + public function receive(Request $request): Response { - //first we trivially reject requets that lack all required inputs - if ((request()->has('target') !== true) || (request()->has('source') !== true)) { + // first we trivially reject requests that lack all required inputs + if (($request->has('target') !== true) || ($request->has('source') !== true)) { return response( 'You need both the target and source parameters', 400 ); } - //next check the $target is valid - $path = parse_url(request()->input('target'), PHP_URL_PATH); + // next check the $target is valid + $path = parse_url($request->input('target'), PHP_URL_PATH); $pathParts = explode('/', $path); - if ($pathParts[1] == 'notes') { - //we have a note + if ($pathParts[1] === 'notes') { + // we have a note $noteId = $pathParts[2]; try { $note = Note::findOrFail(resolve(Numbers::class)->b60tonum($noteId)); - dispatch(new ProcessWebMention($note, request()->input('source'))); + dispatch(new ProcessWebMention($note, $request->input('source'))); } catch (ModelNotFoundException $e) { return response('This note doesn’t exist.', 400); } @@ -60,7 +57,7 @@ class WebMentionsController extends Controller 202 ); } - if ($pathParts[1] == 'blog') { + if ($pathParts[1] === 'blog') { return response( 'I don’t accept webmentions for blog posts yet.', 501 diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php deleted file mode 100644 index e4c35482..00000000 --- a/app/Http/Kernel.php +++ /dev/null @@ -1,68 +0,0 @@ - [ - \App\Http\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - // \Illuminate\Session\Middleware\AuthenticateSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \App\Http\Middleware\LinkHeadersMiddleware::class, - \App\Http\Middleware\LocalhostSessionMiddleware::class, - \App\Http\Middleware\ActivityStreamLinks::class, - \App\Http\Middleware\CSPHeader::class, - ], - - 'api' => [ - 'throttle:60,1', - 'bindings', - ], - ]; - - /** - * The application's route middleware. - * - * These middleware may be assigned to groups or used individually. - * - * @var array - */ - protected $routeMiddleware = [ - 'auth' => \App\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, - 'can' => \Illuminate\Auth\Middleware\Authorize::class, - 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'micropub.token' => \App\Http\Middleware\VerifyMicropubToken::class, - 'myauth' => \App\Http\Middleware\MyAuthMiddleware::class, - 'cors' => \App\Http\Middleware\CorsHeaders::class, - ]; -} diff --git a/app/Http/Middleware/ActivityStreamLinks.php b/app/Http/Middleware/ActivityStreamLinks.php deleted file mode 100644 index 4cad411f..00000000 --- a/app/Http/Middleware/ActivityStreamLinks.php +++ /dev/null @@ -1,31 +0,0 @@ -path() === '/') { - $response->header('Link', '<' . config('app.url') . '>; rel="application/activity+json"', false); - } - if ($request->is('notes/*')) { - $response->header('Link', '<' . $request->url() . '>; rel="application/activity+json"', false); - } - - return $response; - } -} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 00000000..624cd371 --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,20 @@ +expectsJson() ? null : route('login'); + } +} diff --git a/app/Http/Middleware/CSPHeader.php b/app/Http/Middleware/CSPHeader.php deleted file mode 100644 index df57fd22..00000000 --- a/app/Http/Middleware/CSPHeader.php +++ /dev/null @@ -1,72 +0,0 @@ -header( - 'Content-Security-Policy', - str_replace("\\\n", '', "default-src 'self'; \ -script-src 'self' 'unsafe-inline' 'unsafe-eval' \ -https://api.mapbox.com \ -https://analytics.jmb.lv \ -blob:; \ -style-src 'self' 'unsafe-inline' \ -https://api.mapbox.com \ -https://fonts.googleapis.com \ -use.typekit.net \ -p.typekit.net; \ -img-src 'self' data: blob: \ -https://pbs.twimg.com \ -https://api.mapbox.com \ -https://*.tiles.mapbox.com \ -https://jbuk-media.s3-eu-west-1.amazonaws.com \ -https://jbuk-media-dev.s3-eu-west-1.amazonaws.com \ -https://secure.gravatar.com \ -https://graph.facebook.com *.fbcdn.net \ -https://*.cdninstagram.com \ -analytics.jmb.lv \ -https://*.4sqi.net \ -https://upload.wikimedia.org \ -p.typekit.net; \ -font-src 'self' \ -https://fonts.gstatic.com \ -use.typekit.net \ -fonts.typekit.net; \ -connect-src 'self' \ -https://api.mapbox.com \ -https://*.tiles.mapbox.com \ -performance.typekit.net \ -data: blob:; \ -worker-src 'self' blob:; \ -frame-src 'self' https://www.youtube.com blob:; \ -child-src 'self' blob:; \ -upgrade-insecure-requests; \ -block-all-mixed-content; \ -report-to csp-endpoint; \ -report-uri https://jonnybarnes.report-uri.io/r/default/csp/enforce;") - )->header( - 'Report-To', - '{' . - "'url': 'https://jonnybarnes.report-uri.io/r/default/csp/enforce', " . - "'group': 'csp-endpoint'," . - "'max-age': 10886400" . - '}' - ); - } -} diff --git a/app/Http/Middleware/CorsHeaders.php b/app/Http/Middleware/CorsHeaders.php index 85986079..cacf9188 100644 --- a/app/Http/Middleware/CorsHeaders.php +++ b/app/Http/Middleware/CorsHeaders.php @@ -3,17 +3,15 @@ namespace App\Http\Middleware; use Closure; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class CorsHeaders { /** * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ - public function handle($request, Closure $next) + public function handle(Request $request, Closure $next): Response { $response = $next($request); if ($request->path() === 'api/media') { diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php index 033136ad..867695bd 100644 --- a/app/Http/Middleware/EncryptCookies.php +++ b/app/Http/Middleware/EncryptCookies.php @@ -9,7 +9,7 @@ class EncryptCookies extends Middleware /** * The names of the cookies that should not be encrypted. * - * @var array + * @var array */ protected $except = [ // diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index 66dee526..467283db 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -3,23 +3,22 @@ namespace App\Http\Middleware; use Closure; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class LinkHeadersMiddleware { /** * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ - public function handle($request, Closure $next) + public function handle(Request $request, Closure $next): Response { $response = $next($request); - $response->header('Link', '; rel="authorization_endpoint"', false); - $response->header('Link', '<' . config('app.url') . '/api/token>; rel="token_endpoint"', false); - $response->header('Link', '<' . config('app.url') . '/api/post>; rel="micropub"', false); - $response->header('Link', '<' . config('app.url') . '/webmention>; 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/LocalhostSessionMiddleware.php b/app/Http/Middleware/LocalhostSessionMiddleware.php index 5131b9fc..060682d5 100644 --- a/app/Http/Middleware/LocalhostSessionMiddleware.php +++ b/app/Http/Middleware/LocalhostSessionMiddleware.php @@ -6,6 +6,7 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class LocalhostSessionMiddleware { @@ -13,12 +14,8 @@ class LocalhostSessionMiddleware * Whilst we are developing locally, automatically log in as * `['me' => config('app.url')]` as I can’t manually log in as * a .localhost domain. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ - public function handle(Request $request, Closure $next) + public function handle(Request $request, Closure $next): Response { if (config('app.env') !== 'production') { session(['me' => config('app.url')]); diff --git a/app/Http/Middleware/LogMicropubRequest.php b/app/Http/Middleware/LogMicropubRequest.php new file mode 100644 index 00000000..a04e80de --- /dev/null +++ b/app/Http/Middleware/LogMicropubRequest.php @@ -0,0 +1,24 @@ +pushHandler(new StreamHandler(storage_path('logs/micropub.log'))); + $logger->debug('MicropubLog', $request->all()); + + return $next($request); + } +} diff --git a/app/Http/Middleware/MyAuthMiddleware.php b/app/Http/Middleware/MyAuthMiddleware.php index 73b04266..b22e2b33 100644 --- a/app/Http/Middleware/MyAuthMiddleware.php +++ b/app/Http/Middleware/MyAuthMiddleware.php @@ -6,20 +6,20 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Symfony\Component\HttpFoundation\Response; class MyAuthMiddleware { /** * Check the user is logged in. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ - public function handle(Request $request, Closure $next) + public function handle(Request $request, Closure $next): Response { - if ($request->session()->has('loggedin') !== true) { - //they’re not logged in, so send them to login form + if (Auth::check() === false) { + // they’re not logged in, so send them to login form + redirect()->setIntendedUrl($request->fullUrl()); + return redirect()->route('login'); } diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php new file mode 100644 index 00000000..74cbd9a9 --- /dev/null +++ b/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index 92c2fff8..a6a6c8c4 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -2,8 +2,11 @@ namespace App\Http\Middleware; +use App\Providers\RouteServiceProvider; use Closure; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Symfony\Component\HttpFoundation\Response; /** * @codeCoverageIgnore @@ -13,15 +16,16 @@ class RedirectIfAuthenticated /** * Handle an incoming request. * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @param string|null $guard - * @return mixed + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ - public function handle($request, Closure $next, $guard = null) + public function handle(Request $request, Closure $next, string ...$guards): Response { - if (Auth::guard($guard)->check()) { - return redirect('/home'); + $guards = empty($guards) ? [null] : $guards; + + foreach ($guards as $guard) { + if (Auth::guard($guard)->check()) { + return redirect(RouteServiceProvider::HOME); + } } return $next($request); diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php index 5a50e7b5..88cadcaa 100644 --- a/app/Http/Middleware/TrimStrings.php +++ b/app/Http/Middleware/TrimStrings.php @@ -9,9 +9,10 @@ class TrimStrings extends Middleware /** * The names of the attributes that should not be trimmed. * - * @var array + * @var array */ protected $except = [ + 'current_password', 'password', 'password_confirmation', ]; diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php new file mode 100644 index 00000000..9c88c34c --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,23 @@ + + */ + public function hosts(): array + { + return [ + $this->allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index c5bda487..f33f3eef 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -2,22 +2,27 @@ namespace App\Http\Middleware; +use Illuminate\Http\Middleware\TrustProxies as Middleware; use Illuminate\Http\Request; -use Fideloper\Proxy\TrustProxies as Middleware; class TrustProxies extends Middleware { /** * The trusted proxies for this application. * - * @var array + * @var array|string|null */ protected $proxies; /** * The header that should be used to detect proxies. * - * @var string + * @var int */ - protected $headers = Request::HEADER_X_FORWARDED_ALL; + protected $headers = + Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB; } diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 00000000..093bf64a --- /dev/null +++ b/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,22 @@ + + */ + protected $except = [ + // 'fbclid', + // 'utm_campaign', + // 'utm_content', + // 'utm_medium', + // 'utm_source', + // 'utm_term', + ]; +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 1593e373..fc7bad50 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -9,7 +9,7 @@ class VerifyCsrfToken extends Middleware /** * The URIs that should be excluded from CSRF verification. * - * @var array + * @var array */ protected $except = [ 'api/media', diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index aa650560..33d2cb12 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -4,34 +4,78 @@ declare(strict_types=1); namespace App\Http\Middleware; +use App\Http\Responses\MicropubResponses; use Closure; use Illuminate\Http\Request; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Encoding\CannotDecodeContent; +use Lcobucci\JWT\Token; +use Lcobucci\JWT\Token\InvalidTokenStructure; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; +use Symfony\Component\HttpFoundation\Response; class VerifyMicropubToken { /** * Handle an incoming request. * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed + * @param Closure(Request): (Response) $next */ - public function handle(Request $request, Closure $next) + public function handle(Request $request, Closure $next): Response { + $rawToken = null; + if ($request->input('access_token')) { - return $next($request); + $rawToken = $request->input('access_token'); + } elseif ($request->bearerToken()) { + $rawToken = $request->bearerToken(); } - if ($request->bearerToken()) { - return $next($request->merge([ - 'access_token' => $request->bearerToken(), - ])); + if (! $rawToken) { + return response()->json([ + 'response' => 'error', + 'error' => 'unauthorized', + 'error_description' => 'No access token was provided in the request', + ], 401); } - return response()->json([ - 'response' => 'error', - 'error' => 'unauthorized', - 'error_description' => 'No access token was provided in the request', - ], 401); + try { + $tokenData = $this->validateToken($rawToken); + } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { + $micropubResponses = new MicropubResponses; + + return $micropubResponses->invalidTokenResponse(); + } + + if ($tokenData->claims()->has('scope') === false) { + $micropubResponses = new MicropubResponses; + + return $micropubResponses->tokenHasNoScopeResponse(); + } + + return $next($request->merge([ + 'access_token' => $rawToken, + 'token_data' => [ + 'me' => $tokenData->claims()->get('me'), + 'scope' => $tokenData->claims()->get('scope'), + 'client_id' => $tokenData->claims()->get('client_id'), + ], + ])); + } + + /** + * Check the token signature is valid. + */ + private function validateToken(string $bearerToken): Token + { + $config = resolve(Configuration::class); + + $token = $config->parser()->parse($bearerToken); + + $constraints = $config->validationConstraints(); + + $config->validator()->assert($token, ...$constraints); + + return $token; } } diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php new file mode 100644 index 00000000..d931f139 --- /dev/null +++ b/app/Http/Requests/MicropubRequest.php @@ -0,0 +1,106 @@ +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(); + } + } + + 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 (strpos($type, 'h-') === 0) { + $this->micropubData['type'] = substr($type, 2); + } + } + // 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']; + + // 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'] = Arr::get($data, 'location'); + $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; + } + } +} diff --git a/app/Http/Responses/MicropubResponses.php b/app/Http/Responses/MicropubResponses.php new file mode 100644 index 00000000..4f7240c2 --- /dev/null +++ b/app/Http/Responses/MicropubResponses.php @@ -0,0 +1,46 @@ +json([ + 'response' => 'error', + 'error' => 'insufficient_scope', + 'error_description' => 'The token’s scope does not have the necessary requirements.', + ], 401); + } + + /** + * Generate a response to be returned when the token is invalid. + */ + public function invalidTokenResponse(): JsonResponse + { + return response()->json([ + 'response' => 'error', + 'error' => 'invalid_token', + 'error_description' => 'The provided token did not pass validation', + ], 400); + } + + /** + * Generate a response to be returned when the token has no scope. + */ + public function tokenHasNoScopeResponse(): JsonResponse + { + return response()->json([ + 'response' => 'error', + 'error' => 'invalid_request', + 'error_description' => 'The provided token has no scopes', + ], 400); + } +} diff --git a/app/Jobs/AddClientToDatabase.php b/app/Jobs/AddClientToDatabase.php index b7d9c0f4..b540aac0 100644 --- a/app/Jobs/AddClientToDatabase.php +++ b/app/Jobs/AddClientToDatabase.php @@ -4,38 +4,37 @@ declare(strict_types=1); namespace App\Jobs; -use Illuminate\Bus\Queueable; use App\Models\MicropubClient; -use Illuminate\Queue\SerializesModels; -use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; class AddClientToDatabase implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; - protected $client_id; + protected string $client_id; /** * Create a new job instance. - * - * @param string $client_id */ - public function __construct(string $client_id) + public function __construct(string $clientId) { - $this->client_id = $client_id; + $this->client_id = $clientId; } /** * Execute the job. - * - * @return void */ - public function handle() + public function handle(): void { - if (MicropubClient::where('client_url', $this->client_id)->count() == 0) { - $client = MicropubClient::create([ + if (MicropubClient::where('client_url', $this->client_id)->count() === 0) { + MicropubClient::create([ 'client_url' => $this->client_id, 'client_name' => $this->client_id, // default client name is the URL ]); diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php index a3e311e6..3c187dd4 100644 --- a/app/Jobs/DownloadWebMention.php +++ b/app/Jobs/DownloadWebMention.php @@ -5,67 +5,62 @@ declare(strict_types=1); namespace App\Jobs; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use Illuminate\Bus\Queueable; -use Illuminate\FileSystem\FileSystem; -use Illuminate\Queue\SerializesModels; -use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\FileSystem\FileSystem; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; class DownloadWebMention implements ShouldQueue { - use InteractsWithQueue, Queueable, SerializesModels; - - /** - * The webmention source URL. - * - * @var - */ - protected $source; + use InteractsWithQueue; + use Queueable; + use SerializesModels; /** * Create a new job instance. - * - * @param string $source */ - public function __construct(string $source) - { - $this->source = $source; - } + public function __construct( + protected string $source + ) {} /** * Execute the job. * - * @param \GuzzleHttp\Client $guzzle + * @throws GuzzleException + * @throws FileNotFoundException */ - public function handle(Client $guzzle) + public function handle(Client $guzzle): 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') { - $filesystem = new FileSystem(); + // 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); - //backup file first + // backup file first $filenameBackup = $filename . '.' . date('Y-m-d') . '.backup'; if ($filesystem->exists($filename)) { $filesystem->copy($filename, $filenameBackup); } - //check if base directory exists + // check if base directory exists if (! $filesystem->exists($filesystem->dirname($filename))) { $filesystem->makeDirectory( $filesystem->dirname($filename), - 0755, //mode - true //recursive + 0755, // mode + true // recursive ); } - //save new HTML + // save new HTML $filesystem->put( $filename, (string) $response->getBody() ); - //remove backup if the same + // remove backup if the same if ($filesystem->exists($filenameBackup)) { - if ($filesystem->get($filename) == $filesystem->get($filenameBackup)) { + if ($filesystem->get($filename) === $filesystem->get($filenameBackup)) { $filesystem->delete($filenameBackup); } } @@ -73,16 +68,12 @@ class DownloadWebMention implements ShouldQueue } /** - * Create a file path from a URL. This is used when caching the HTML - * response. - * - * @param string The URL - * @return string The path name + * Create a file path from a URL. This is used when caching the HTML response. */ - private function createFilenameFromURL($url) + private function createFilenameFromURL(string $url): string { $filepath = str_replace(['https://', 'http://'], ['https/', 'http/'], $url); - if (substr($filepath, -1) == '/') { + if (str_ends_with($filepath, '/')) { $filepath .= 'index.html'; } diff --git a/app/Jobs/ProcessBookmark.php b/app/Jobs/ProcessBookmark.php index 491c2b5c..96f65e87 100644 --- a/app/Jobs/ProcessBookmark.php +++ b/app/Jobs/ProcessBookmark.php @@ -4,44 +4,39 @@ declare(strict_types=1); namespace App\Jobs; +use App\Exceptions\InternetArchiveException; use App\Models\Bookmark; -use Illuminate\Bus\Queueable; use App\Services\BookmarkService; -use Illuminate\Queue\SerializesModels; -use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; -use App\Exceptions\InternetArchiveException; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; class ProcessBookmark implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - protected $bookmark; + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; /** * Create a new job instance. - * - * @param \App\Models\Bookmark $bookmark */ - public function __construct(Bookmark $bookmark) - { - $this->bookmark = $bookmark; - } + public function __construct( + protected Bookmark $bookmark + ) {} /** * Execute the job. - * - * @return void */ - public function handle() + public function handle(): void { - $uuid = (resolve(BookmarkService::class))->saveScreenshot($this->bookmark->url); - $this->bookmark->screenshot = $uuid; + SaveScreenshot::dispatch($this->bookmark); try { $archiveLink = (resolve(BookmarkService::class))->getArchiveLink($this->bookmark->url); - } catch (InternetArchiveException $e) { + } catch (InternetArchiveException) { $archiveLink = null; } $this->bookmark->archive = $archiveLink; diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php index 78976a8c..3c6028a9 100644 --- a/app/Jobs/ProcessLike.php +++ b/app/Jobs/ProcessLike.php @@ -5,52 +5,53 @@ 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 Thujohn\Twitter\Facades\Twitter; -use Illuminate\Queue\SerializesModels; -use Illuminate\Queue\InteractsWithQueue; -use GuzzleHttp\Exception\ClientException; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Arr; use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; class ProcessLike implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - protected $like; + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; /** * Create a new job instance. - * - * @param \App\Models\Like $like */ - public function __construct(Like $like) - { - $this->like = $like; - } + public function __construct( + protected Like $like + ) {} /** * Execute the job. * - * @param \GuzzleHttp\Client $client - * @param \Jonnybarnes\WebmentionsParser\Authorship $authorship - * @return int + * @throws GuzzleException */ public function handle(Client $client, Authorship $authorship): int { if ($this->isTweet($this->like->url)) { - $tweet = Twitter::getOembed(['url' => $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 + // POSSE like try { - $response = $client->request( + $client->request( 'POST', 'https://brid.gy/publish/webmention', [ @@ -60,8 +61,8 @@ class ProcessLike implements ShouldQueue ], ] ); - } catch (ClientException $exception) { - //no biggie + } catch (RequestException) { + return 0; } return 0; @@ -69,15 +70,15 @@ class ProcessLike implements ShouldQueue $response = $client->request('GET', $this->like->url); $mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url); - if (array_has($mf2, 'items.0.properties.content')) { + if (Arr::has($mf2, 'items.0.properties.content')) { $this->like->content = $mf2['items'][0]['properties']['content'][0]['html']; } try { $author = $authorship->findAuthor($mf2); if (is_array($author)) { - $this->like->author_name = array_get($author, 'properties.name.0'); - $this->like->author_url = array_get($author, 'properties.url.0'); + $this->like->author_name = Arr::get($author, 'properties.name.0'); + $this->like->author_url = Arr::get($author, 'properties.url.0'); } if (is_string($author) && $author !== '') { $this->like->author_name = $author; @@ -93,9 +94,6 @@ class ProcessLike implements ShouldQueue /** * Determine if a given URL is that of a Tweet. - * - * @param string $url - * @return bool */ private function isTweet(string $url): bool { diff --git a/app/Jobs/ProcessMedia.php b/app/Jobs/ProcessMedia.php index 9efbe37a..b7f36648 100644 --- a/app/Jobs/ProcessMedia.php +++ b/app/Jobs/ProcessMedia.php @@ -4,73 +4,66 @@ declare(strict_types=1); namespace App\Jobs; -use Illuminate\Http\File; use Illuminate\Bus\Queueable; -use Intervention\Image\ImageManager; -use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Storage; -use Illuminate\Queue\InteractsWithQueue; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; -use Intervention\Image\Exception\NotReadableException; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Storage; +use Intervention\Image\Exceptions\DecoderException; +use Intervention\Image\ImageManager; class ProcessMedia implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - protected $filename; + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; /** * Create a new job instance. - * - * @param string $filename */ - public function __construct(string $filename) - { - $this->filename = $filename; - } + public function __construct( + protected string $filename + ) {} /** * Execute the job. - * - * @param \Intervention\Image\ImageManager $manager */ - public function handle(ImageManager $manager) + public function handle(ImageManager $manager): void { - Storage::disk('s3')->putFileAs( - 'media', - new File(storage_path('app') . '/' . $this->filename), - $this->filename - ); - //open file + // Load file + $file = Storage::disk('local')->get('media/' . $this->filename); + + // Open file try { - $image = $manager->make(storage_path('app') . '/' . $this->filename); - } catch (NotReadableException $exception) { + $image = $manager->read($file); + } catch (DecoderException) { // not an image; delete file and end job - unlink(storage_path('app') . '/' . $this->filename); + Storage::disk('local')->delete('media/' . $this->filename); return; } - //create smaller versions if necessary + + // Save the file publicly + Storage::disk('public')->put('media/' . $this->filename, $file); + + // Create smaller versions if necessary if ($image->width() > 1000) { $filenameParts = explode('.', $this->filename); $extension = array_pop($filenameParts); - // the following acheives this data flow + // the following achieves this data flow // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar - $basename = ltrim(array_reduce($filenameParts, function ($carry, $item) { - return $carry . '.' . $item; - }, ''), '.'); - $medium = $image->resize(1000, null, function ($constraint) { - $constraint->aspectRatio(); - }); - Storage::disk('s3')->put('media/'. $basename . '-medium.' . $extension, (string) $medium->encode()); - $small = $image->resize(500, null, function ($constraint) { - $constraint->aspectRatio(); - }); - Storage::disk('s3')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); + $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()); } - // now we can delete the locally saved image - unlink(storage_path('app') . '/' . $this->filename); + // Now we can delete the locally saved image + Storage::disk('local')->delete('media/' . $this->filename); } } diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index e748ae77..d92dfa18 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -4,42 +4,42 @@ declare(strict_types=1); namespace App\Jobs; -use Mf2; -use GuzzleHttp\Client; -use Illuminate\Bus\Queueable; -use App\Models\{Note, WebMention}; -use Jonnybarnes\WebmentionsParser\Parser; -use GuzzleHttp\Exception\RequestException; -use Illuminate\Contracts\Queue\ShouldQueue; use App\Exceptions\RemoteContentNotFoundException; -use Illuminate\Queue\{InteractsWithQueue, SerializesModels}; +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\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException; +use Jonnybarnes\WebmentionsParser\Parser; +use Mf2; class ProcessWebMention implements ShouldQueue { - use InteractsWithQueue, Queueable, SerializesModels; - - protected $note; - protected $source; + use InteractsWithQueue; + use Queueable; + use SerializesModels; /** * Create a new job instance. - * - * @param \App\Note $note - * @param string $source */ - public function __construct(Note $note, $source) - { - $this->note = $note; - $this->source = $source; - } + public function __construct( + protected Note $note, + protected string $source + ) {} /** * Execute the job. * - * @param \Jonnybarnes\WebmentionsParser\Parser $parser - * @param \GuzzleHttp\Client $guzzle + * @throws RemoteContentNotFoundException + * @throws GuzzleException + * @throws InvalidMentionException */ - public function handle(Parser $parser, Client $guzzle) + public function handle(Parser $parser, Client $guzzle): void { try { $response = $guzzle->request('GET', $this->source); @@ -52,30 +52,30 @@ class ProcessWebMention implements ShouldQueue foreach ($webmentions as $webmention) { // check webmention still references target // we try each type of mention (reply/like/repost) - if ($webmention->type == 'in-reply-to') { - if ($parser->checkInReplyTo($microformats, $this->note->longurl) == false) { + if ($webmention->type === 'in-reply-to') { + if ($parser->checkInReplyTo($microformats, $this->note->uri) === false) { // it doesn’t so delete $webmention->delete(); return; } - // webmenion is still a reply, so update content + // webmention is still a reply, so update content dispatch(new SaveProfileImage($microformats)); $webmention->mf2 = json_encode($microformats); $webmention->save(); return; } - if ($webmention->type == 'like-of') { - if ($parser->checkLikeOf($microformats, $note->longurl) == false) { + if ($webmention->type === 'like-of') { + if ($parser->checkLikeOf($microformats, $this->note->uri) === false) { // it doesn’t so delete $webmention->delete(); return; } // note we don’t need to do anything if it still is a like } - if ($webmention->type == 'repost-of') { - if ($parser->checkRepostOf($microformats, $note->longurl) == false) { + if ($webmention->type === 'repost-of') { + if ($parser->checkRepostOf($microformats, $this->note->uri) === false) { // it doesn’t so delete $webmention->delete(); @@ -85,13 +85,13 @@ class ProcessWebMention implements ShouldQueue }// foreach // no webmention in the db so create new one - $webmention = new WebMention(); + $webmention = new WebMention; $type = $parser->getMentionType($microformats); // throw error here? dispatch(new SaveProfileImage($microformats)); $webmention->source = $this->source; - $webmention->target = $this->note->longurl; + $webmention->target = $this->note->uri; $webmention->commentable_id = $this->note->id; - $webmention->commentable_type = 'App\Note'; + $webmention->commentable_type = Note::class; $webmention->type = $type; $webmention->mf2 = json_encode($microformats); $webmention->save(); @@ -99,26 +99,23 @@ class ProcessWebMention implements ShouldQueue /** * Save the HTML of a webmention for future use. - * - * @param string $html - * @param string $url */ - private function saveRemoteContent($html, $url) + private function saveRemoteContent(string $html, string $url): void { $filenameFromURL = str_replace( ['https://', 'http://'], ['https/', 'http/'], $url ); - if (substr($url, -1) == '/') { + if (str_ends_with($url, '/')) { $filenameFromURL .= 'index.html'; } $path = storage_path() . '/HTML/' . $filenameFromURL; $parts = explode('/', $path); $name = array_pop($parts); $dir = implode('/', $parts); - if (! is_dir($dir)) { - mkdir($dir, 0755, true); + if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir)); } file_put_contents("$dir/$name", $html); } diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php index abdebae6..08152d5b 100644 --- a/app/Jobs/SaveProfileImage.php +++ b/app/Jobs/SaveProfileImage.php @@ -5,64 +5,75 @@ declare(strict_types=1); namespace App\Jobs; use GuzzleHttp\Client; -use Illuminate\Bus\Queueable; -use Illuminate\Queue\SerializesModels; -use Illuminate\Queue\InteractsWithQueue; use GuzzleHttp\Exception\RequestException; +use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Arr; use Jonnybarnes\WebmentionsParser\Authorship; use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException; class SaveProfileImage implements ShouldQueue { - use InteractsWithQueue, Queueable, SerializesModels; - - protected $microformats; + use InteractsWithQueue; + use Queueable; + use SerializesModels; /** * Create a new job instance. - * - * @param array $microformats */ - public function __construct(array $microformats) - { - $this->microformats = $microformats; - } + public function __construct( + protected array $microformats + ) {} /** * Execute the job. - * - * @param \Jonnybarnes\WebmentionsParser\Authorship $authorship */ - public function handle(Authorship $authorship) + public function handle(Authorship $authorship): void { try { $author = $authorship->findAuthor($this->microformats); - } catch (AuthorshipParserException $e) { + } catch (AuthorshipParserException) { return; } - $photo = $author['properties']['photo'][0]; - $home = $author['properties']['url'][0]; - //dont save pbs.twimg.com links - if (parse_url($photo, PHP_URL_HOST) != 'pbs.twimg.com' - && parse_url($photo, PHP_URL_HOST) != 'twitter.com') { + + $photo = Arr::get($author, 'properties.photo.0'); + $home = Arr::get($author, 'properties.url.0'); + + if (is_array($photo) && array_key_exists('value', $photo)) { + $photo = $photo['value']; + } + + if (is_array($home)) { + $home = array_shift($home); + } + + // dont save pbs.twimg.com links + if ( + $photo + && 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(true); - } catch (RequestException $e) { - // we are openning and reading the default image so that + $image = $response->getBody(); + } catch (RequestException) { + // we are opening and reading the default image so that $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'; $parts = explode('/', $path); $name = array_pop($parts); $dir = implode('/', $parts); - if (! is_dir($dir)) { - mkdir($dir, 0755, true); + if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir)); } file_put_contents("$dir/$name", $image); } diff --git a/app/Jobs/SaveScreenshot.php b/app/Jobs/SaveScreenshot.php new file mode 100755 index 00000000..0e07efbd --- /dev/null +++ b/app/Jobs/SaveScreenshot.php @@ -0,0 +1,103 @@ +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 = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->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', + ], + ]); + + $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 = $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 = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url; + + $finalImageUrlContent = $client->request('GET', $finalImageUrl); + + Storage::disk('public')->put('/assets/img/bookmarks/' . $taskId . '.png', $finalImageUrlContent->getBody()->getContents()); + + $this->bookmark->screenshot = $taskId; + $this->bookmark->save(); + } +} diff --git a/app/Jobs/SendWebMentions.php b/app/Jobs/SendWebMentions.php index e60946ff..2ff5f2c6 100644 --- a/app/Jobs/SendWebMentions.php +++ b/app/Jobs/SendWebMentions.php @@ -6,47 +6,46 @@ 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; use Illuminate\Bus\Queueable; -use Illuminate\Queue\SerializesModels; -use Illuminate\Queue\InteractsWithQueue; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Str; class SendWebMentions implements ShouldQueue { - use InteractsWithQueue, Queueable, SerializesModels; - - protected $note; + use InteractsWithQueue; + use Queueable; + use SerializesModels; /** - * Create the job instance, inject dependencies. - * - * @param Note $note + * Create a new job instance. */ - public function __construct(Note $note) - { - $this->note = $note; - } + public function __construct( + protected Note $note + ) {} /** * Execute the job. * - * @return void + * @throws GuzzleException */ - public function handle() + public function handle(): void { - //grab the URLs - $inReplyTo = $this->note->in_reply_to ?? ''; - // above so explode doesn’t complain about null being passed in - $urlsInReplyTo = explode(' ', $inReplyTo); + $urlsInReplyTo = explode(' ', $this->note->in_reply_to ?? ''); $urlsNote = $this->getLinks($this->note->note); - $urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); //filter out none URLs + $urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); foreach ($urls as $url) { $endpoint = $this->discoverWebmentionEndpoint($url); if ($endpoint !== null) { $guzzle = resolve(Client::class); $guzzle->post($endpoint, [ 'form_params' => [ - 'source' => $this->note->longurl, + 'source' => $this->note->uri, 'target' => $url, ], ]); @@ -57,32 +56,31 @@ class SendWebMentions implements ShouldQueue /** * Discover if a URL has a webmention endpoint. * - * @param string $url - * @return string|null + * @throws GuzzleException */ - public function discoverWebmentionEndpoint(string $url) + public function discoverWebmentionEndpoint(string $url): ?string { - //let’s not send webmentions to myself - if (parse_url($url, PHP_URL_HOST) == config('app.longurl')) { - return; + // let’s not send webmentions to myself + if (parse_url($url, PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST)) { + return null; } - if (starts_with($url, '/notes/tagged/')) { - return; + if (Str::startsWith($url, '/notes/tagged/')) { + return null; } $endpoint = null; $guzzle = resolve(Client::class); $response = $guzzle->get($url); - //check HTTP Headers for webmention endpoint - $links = \GuzzleHttp\Psr7\parse_header($response->getHeader('Link')); + // check HTTP Headers for webmention endpoint + $links = Header::parse($response->getHeader('Link')); foreach ($links as $link) { - if (mb_stristr($link['rel'], 'webmention')) { + if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { return $this->resolveUri(trim($link[0], '<>'), $url); } } - //failed to find a header so parse HTML + // failed to find a header so parse HTML $html = (string) $response->getBody(); $mf2 = new \Mf2\Parser($html, $url); @@ -92,24 +90,25 @@ class SendWebMentions implements ShouldQueue } elseif (array_key_exists('http://webmention.org/', $rels[0])) { $endpoint = $rels[0]['http://webmention.org/'][0]; } - if ($endpoint) { - return $this->resolveUri($endpoint, $url); + + if ($endpoint === null) { + return null; } + + return $this->resolveUri($endpoint, $url); } /** * Get the URLs from a note. - * - * @param string $html - * @return array $urls */ - public function getLinks($html) + public function getLinks(?string $html): array { - if ($html == '' || is_null($html)) { + if ($html === '' || is_null($html)) { return []; } + $urls = []; - $dom = new \DOMDocument(); + $dom = new \DOMDocument; $dom->loadHTML($html); $anchors = $dom->getElementsByTagName('a'); foreach ($anchors as $anchor) { @@ -121,20 +120,16 @@ class SendWebMentions implements ShouldQueue /** * Resolve a URI if necessary. - * - * @param string $url - * @param string $base The base of the URL - * @return string */ public function resolveUri(string $url, string $base): string { - $endpoint = \GuzzleHttp\Psr7\uri_for($url); - if ($endpoint->getScheme() != '') { + $endpoint = Utils::uriFor($url); + if ($endpoint->getScheme() !== '') { return (string) $endpoint; } - return (string) \GuzzleHttp\Psr7\Uri::resolve( - \GuzzleHttp\Psr7\uri_for($base), + return (string) UriResolver::resolve( + Utils::uriFor($base), $endpoint ); } diff --git a/app/Jobs/SyndicateBookmarkToFacebook.php b/app/Jobs/SyndicateBookmarkToFacebook.php deleted file mode 100644 index 973ea249..00000000 --- a/app/Jobs/SyndicateBookmarkToFacebook.php +++ /dev/null @@ -1,59 +0,0 @@ -bookmark = $bookmark; - } - - /** - * Execute the job. - * - * @param \GuzzleHttp\Client $guzzle - */ - public function handle(Client $guzzle) - { - //send webmention - $response = $guzzle->request( - 'POST', - 'https://brid.gy/publish/webmention', - [ - 'form_params' => [ - 'source' => $this->bookmark->longurl, - 'target' => 'https://brid.gy/publish/facebook', - 'bridgy_omit_link' => 'maybe', - ], - ] - ); - //parse for syndication URL - if ($response->getStatusCode() == 201) { - $json = json_decode((string) $response->getBody()); - $syndicates = $this->bookmark->syndicates; - $syndicates['facebook'] = $json->url; - $this->bookmark->syndicates = $syndicates; - $this->bookmark->save(); - } - } -} diff --git a/app/Jobs/SyndicateBookmarkToTwitter.php b/app/Jobs/SyndicateBookmarkToTwitter.php deleted file mode 100644 index 289a6f0e..00000000 --- a/app/Jobs/SyndicateBookmarkToTwitter.php +++ /dev/null @@ -1,59 +0,0 @@ -bookmark = $bookmark; - } - - /** - * Execute the job. - * - * @param \GuzzleHttp\Client $guzzle - */ - public function handle(Client $guzzle) - { - //send webmention - $response = $guzzle->request( - 'POST', - 'https://brid.gy/publish/webmention', - [ - 'form_params' => [ - 'source' => $this->bookmark->longurl, - 'target' => 'https://brid.gy/publish/twitter', - 'bridgy_omit_link' => 'maybe', - ], - ] - ); - //parse for syndication URL - if ($response->getStatusCode() == 201) { - $json = json_decode((string) $response->getBody()); - $syndicates = $this->bookmark->syndicates; - $syndicates['twitter'] = $json->url; - $this->bookmark->syndicates = $syndicates; - $this->bookmark->save(); - } - } -} diff --git a/app/Jobs/SyndicateNoteToBluesky.php b/app/Jobs/SyndicateNoteToBluesky.php new file mode 100644 index 00000000..e815be34 --- /dev/null +++ b/app/Jobs/SyndicateNoteToBluesky.php @@ -0,0 +1,62 @@ +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/SyndicateNoteToFacebook.php b/app/Jobs/SyndicateNoteToFacebook.php deleted file mode 100644 index 74cda117..00000000 --- a/app/Jobs/SyndicateNoteToFacebook.php +++ /dev/null @@ -1,56 +0,0 @@ -note = $note; - } - - /** - * Execute the job. - * - * @param \GuzzleHttp\Client $guzzle - */ - public function handle(Client $guzzle) - { - //send webmention - $response = $guzzle->request( - 'POST', - 'https://brid.gy/publish/webmention', - [ - 'form_params' => [ - 'source' => $this->note->longurl, - 'target' => 'https://brid.gy/publish/facebook', - 'bridgy_omit_link' => 'maybe', - ], - ] - ); - //parse for syndication URL - if ($response->getStatusCode() == 201) { - $json = json_decode((string) $response->getBody()); - $this->note->facebook_url = $json->url; - $this->note->save(); - } - } -} diff --git a/app/Jobs/SyndicateNoteToMastodon.php b/app/Jobs/SyndicateNoteToMastodon.php new file mode 100644 index 00000000..b79c092c --- /dev/null +++ b/app/Jobs/SyndicateNoteToMastodon.php @@ -0,0 +1,63 @@ +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/Jobs/SyndicateNoteToTwitter.php b/app/Jobs/SyndicateNoteToTwitter.php deleted file mode 100644 index 712aa461..00000000 --- a/app/Jobs/SyndicateNoteToTwitter.php +++ /dev/null @@ -1,57 +0,0 @@ -note = $note; - } - - /** - * Execute the job. - * - * @param \GuzzleHttp\Client $guzzle - */ - public function handle(Client $guzzle) - { - //send webmention - $response = $guzzle->request( - 'POST', - 'https://brid.gy/publish/webmention', - [ - 'form_params' => [ - 'source' => $this->note->longurl, - 'target' => 'https://brid.gy/publish/twitter', - 'bridgy_omit_link' => 'maybe', - ], - ] - ); - //parse for syndication URL - if ($response->getStatusCode() == 201) { - $json = json_decode((string) $response->getBody()); - $tweet_id = basename(parse_url($json->url, PHP_URL_PATH)); - $this->note->tweet_id = $tweet_id; - $this->note->save(); - } - } -} diff --git a/app/Models/Article.php b/app/Models/Article.php index 44fd1411..bfbd5d51 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -4,35 +4,46 @@ declare(strict_types=1); namespace App\Models; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Builder; use Cviebrock\EloquentSluggable\Sluggable; -use League\CommonMark\CommonMarkConverter; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; +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; class Article extends Model { + use HasFactory; use Sluggable; use SoftDeletes; - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = ['created_at', 'updated_at', 'deleted_at']; - - /** - * The database table used by the model. - * - * @var string - */ + /** @var string */ protected $table = 'articles'; + /** @var array */ + protected $fillable = [ + 'url', + 'title', + 'main', + 'published', + ]; + + /** @var array */ + protected $casts = [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + /** * Return the sluggable configuration array for this model. - * - * @return array */ public function sluggable(): array { @@ -43,89 +54,62 @@ class Article extends Model ]; } - /** - * We shall set a blacklist of non-modifiable model attributes. - * - * @var array - */ - protected $guarded = ['id']; - - /** - * Process the article for display. - * - * @return string - */ - public function getHtmlAttribute(): string + protected function html(): Attribute { - $markdown = new CommonMarkConverter(); - $html = $markdown->convertToHtml($this->main); - // changes
[lang] ~> 

-        $match = '/
\[(.*)\]\n/';
-        $replace = '
';
-        $text = preg_replace($match, $replace, $html);
-        $default = preg_replace('/
/', '
', $text);
+        return Attribute::get(
+            get: function () {
+                $environment = new Environment;
+                $environment->addExtension(new CommonMarkCoreExtension);
+                $environment->addRenderer(FencedCode::class, new FencedCodeRenderer);
+                $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer);
+                $markdownConverter = new MarkdownConverter($environment);
 
-        return $default;
+                return $markdownConverter->convert($this->main)->getContent();
+            },
+        );
     }
 
-    /**
-     * Convert updated_at to W3C time format.
-     *
-     * @return string
-     */
-    public function getW3cTimeAttribute(): string
+    protected function w3cTime(): Attribute
     {
-        return $this->updated_at->toW3CString();
+        return Attribute::get(
+            get: fn () => $this->updated_at->toW3CString(),
+        );
     }
 
-    /**
-     * Convert updated_at to a tooltip appropriate format.
-     *
-     * @return string
-     */
-    public function getTooltipTimeAttribute(): string
+    protected function tooltipTime(): Attribute
     {
-        return $this->updated_at->toRFC850String();
+        return Attribute::get(
+            get: fn () => $this->updated_at->toRFC850String(),
+        );
     }
 
-    /**
-     * Convert updated_at to a human readable format.
-     *
-     * @return string
-     */
-    public function getHumanTimeAttribute(): string
+    protected function humanTime(): Attribute
     {
-        return $this->updated_at->diffForHumans();
+        return Attribute::get(
+            get: fn () => $this->updated_at->diffForHumans(),
+        );
     }
 
-    /**
-     * Get the pubdate value for RSS feeds.
-     *
-     * @return string
-     */
-    public function getPubdateAttribute(): string
+    protected function pubdate(): Attribute
     {
-        return $this->updated_at->toRSSString();
+        return Attribute::get(
+            get: fn () => $this->updated_at->toRSSString(),
+        );
     }
 
-    /**
-     * A link to the article, i.e. `/blog/1999/12/25/merry-christmas`.
-     *
-     * @return string
-     */
-    public function getLinkAttribute(): string
+    protected function link(): Attribute
     {
-        return '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl;
+        return Attribute::get(
+            get: fn () => '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl,
+        );
     }
 
     /**
      * Scope a query to only include articles from a particular year/month.
-     *
-     * @return \Illuminate\Database\Eloquent\Builder
      */
-    public function scopeDate($query, int $year = null, int $month = null): Builder
+    public function scopeDate(Builder $query, ?int $year = null, ?int $month = null): Builder
     {
-        if ($year == null) {
+        if ($year === null) {
             return $query;
         }
         $start = $year . '-01-01 00:00:00';
diff --git a/app/Models/Bio.php b/app/Models/Bio.php
new file mode 100644
index 00000000..b9a0e78b
--- /dev/null
+++ b/app/Models/Bio.php
@@ -0,0 +1,11 @@
+ */
     protected $fillable = ['url', 'name', 'content'];
 
-    /**
-     * The attributes that should be cast to native types.
-     *
-     * @var array
-     */
+    /** @var array */
     protected $casts = [
         'syndicates' => 'array',
     ];
 
-    /**
-     * The tags that belong to the bookmark.
-     *
-     * @return  \Illuminate\Database\Eloquent\Relations\BelongsToMany
-     */
-    public function tags()
+    public function tags(): BelongsToMany
     {
         return $this->belongsToMany('App\Models\Tag');
     }
 
-    /**
-     * The full url of a bookmark.
-     *
-     * @return string
-     */
-    public function getLongurlAttribute(): string
+    protected function local_uri(): Attribute
     {
-        return config('app.url') . '/bookmarks/' . $this->id;
+        return Attribute::get(
+            get: fn () => config('app.url') . '/bookmarks/' . $this->id,
+        );
     }
 }
diff --git a/app/Models/Contact.php b/app/Models/Contact.php
index 5cf4bb82..6f193f41 100644
--- a/app/Models/Contact.php
+++ b/app/Models/Contact.php
@@ -4,21 +4,33 @@ declare(strict_types=1);
 
 namespace App\Models;
 
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 
 class Contact extends Model
 {
-    /**
-     * The database table used by the model.
-     *
-     * @var string
-     */
+    use HasFactory;
+
+    /** @var string */
     protected $table = 'contacts';
 
-    /**
-     * We shall guard against mass-migration.
-     *
-     * @var array
-     */
+    /** @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';
+            }
+        }
+
+        return Attribute::make(
+            get: fn () => $photo,
+        );
+    }
 }
diff --git a/app/Models/Like.php b/app/Models/Like.php
index b3bc09a6..f9ac3bcb 100644
--- a/app/Models/Like.php
+++ b/app/Models/Like.php
@@ -4,71 +4,53 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use Mf2;
-use HTMLPurifier;
-use HTMLPurifier_Config;
+use App\Traits\FilterHtml;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Arr;
+use Mf2;
 
 class Like extends Model
 {
+    use FilterHtml;
+    use HasFactory;
+
+    /** @var array */
     protected $fillable = ['url'];
 
-    /**
-     * Normalize the URL of a Like.
-     *
-     * @param  string  $value The provided URL
-     */
-    public function setUrlAttribute(string $value)
+    protected function url(): Attribute
     {
-        $this->attributes['url'] = normalize_url($value);
+        return Attribute::set(
+            set: fn ($value) => normalize_url($value),
+        );
     }
 
-    /**
-     * Normalize the URL of the author of the like.
-     *
-     * @param  string  $value The author’s url
-     */
-    public function setAuthorUrlAttribute(?string $value)
+    protected function authorUrl(): Attribute
     {
-        $this->attributes['author_url'] = normalize_url($value);
+        return Attribute::set(
+            set: fn ($value) => normalize_url($value),
+        );
     }
 
-    /**
-     * If the content contains HTML, filter it.
-     *
-     * @param  string  $value The content of the like
-     * @return string|null
-     */
-    public function getContentAttribute(?string $value): ?string
+    protected function content(): Attribute
     {
-        if ($value === null) {
-            return null;
-        }
+        return Attribute::get(
+            get: function ($value, $attributes) {
+                if ($value === null) {
+                    return null;
+                }
 
-        $mf2 = Mf2\parse($value, $this->url);
+                $mf2 = Mf2\parse($value, $attributes['url']);
 
-        if (array_get($mf2, 'items.0.properties.content.0.html')) {
-            return $this->filterHTML(
-                $mf2['items'][0]['properties']['content'][0]['html']
-            );
-        }
+                if (Arr::get($mf2, 'items.0.properties.content.0.html')) {
+                    return $this->filterHtml(
+                        $mf2['items'][0]['properties']['content'][0]['html']
+                    );
+                }
 
-        return $value;
-    }
-
-    /**
-     * Filter some HTML with HTMLPurifier.
-     *
-     * @param  string  $html
-     * @return string
-     */
-    private function filterHTML(string $html): string
-    {
-        $config = HTMLPurifier_Config::createDefault();
-        $config->set('Cache.SerializerPath', storage_path() . '/HTMLPurifier');
-        $config->set('HTML.TargetBlank', true);
-        $purifier = new HTMLPurifier($config);
-
-        return $purifier->purify($html);
+                return $value;
+            }
+        );
     }
 }
diff --git a/app/Models/Media.php b/app/Models/Media.php
index 4d25c74f..3d923bed 100644
--- a/app/Models/Media.php
+++ b/app/Models/Media.php
@@ -4,101 +4,96 @@ declare(strict_types=1);
 
 namespace App\Models;
 
+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\Support\Str;
 
 class Media extends Model
 {
-    /**
-     * The table associated with the model.
-     *
-     * @var string
-     */
+    use HasFactory;
+
+    /** @var string */
     protected $table = 'media_endpoint';
 
-    /**
-     * The attributes that are mass assignable.
-     *
-     * @var array
-     */
+    /** @var array */
     protected $fillable = ['token', 'path', 'type', 'image_widths'];
 
-    /**
-     * Get the note that owns this media.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
-     */
     public function note(): BelongsTo
     {
-        return $this->belongsTo('App\Models\Note');
+        return $this->belongsTo(Note::class);
     }
 
-    /**
-     * Get the URL for an S3 media file.
-     *
-     * @return string
-     */
-    public function getUrlAttribute(): string
+    protected function url(): Attribute
     {
-        if (starts_with($this->path, 'https://')) {
-            return $this->path;
-        }
+        return Attribute::get(
+            get: function ($value, $attributes) {
+                if (Str::startsWith($attributes['path'], 'https://')) {
+                    return $attributes['path'];
+                }
 
-        return config('filesystems.disks.s3.url') . '/' . $this->path;
+                return config('app.url') . '/storage/' . $attributes['path'];
+            }
+        );
     }
 
-    /**
-     * Get the URL for the medium size of an S3 image file.
-     *
-     * @return string
-     */
-    public function getMediumurlAttribute(): string
+    protected function mediumurl(): Attribute
     {
-        $basename = $this->getBasename($this->path);
-        $extension = $this->getExtension($this->path);
-
-        return config('filesystems.disks.s3.url') . '/' . $basename . '-medium.' . $extension;
+        return Attribute::get(
+            get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'medium'),
+        );
     }
 
-    /**
-     * Get the URL for the small size of an S3 image file.
-     *
-     * @return string
-     */
-    public function getSmallurlAttribute(): string
+    protected function smallurl(): Attribute
     {
-        $basename = $this->getBasename($this->path);
-        $extension = $this->getExtension($this->path);
-
-        return config('filesystems.disks.s3.url') . '/' . $basename . '-small.' . $extension;
+        return Attribute::get(
+            get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'small'),
+        );
     }
 
-    /**
-     * Give the real part of a filename, i.e. strip the file extension.
-     *
-     * @param  string  $path
-     * @return string
-     */
-    public function getBasename(string $path): string
+    protected function mimetype(): Attribute
+    {
+        return Attribute::get(
+            get: function ($value, $attributes) {
+                $extension = $this->getExtension($attributes['path']);
+
+                return match ($extension) {
+                    'gif' => 'image/gif',
+                    'jpeg', 'jpg' => 'image/jpeg',
+                    'png' => 'image/png',
+                    'svg' => 'image/svg+xml',
+                    'tiff' => 'image/tiff',
+                    'webp' => 'image/webp',
+                    'mp4' => 'video/mp4',
+                    'mkv' => 'video/mkv',
+                    default => 'application/octet-stream',
+                };
+            },
+        );
+    }
+
+    private function getSizeUrl(string $path, string $size): string
+    {
+        $basename = $this->getBasename($path);
+        $extension = $this->getExtension($path);
+
+        return config('app.url') . '/storage/' . $basename . '-' . $size . '.' . $extension;
+    }
+
+    private function getBasename(string $path): string
     {
         // the following achieves this data flow
         // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
         $filenameParts = explode('.', $path);
         array_pop($filenameParts);
-        $basename = ltrim(array_reduce($filenameParts, function ($carry, $item) {
+
+        return ltrim(array_reduce($filenameParts, static function ($carry, $item) {
             return $carry . '.' . $item;
         }, ''), '.');
-
-        return $basename;
     }
 
-    /**
-     * Get the extension from a given filename.
-     *
-     * @param  string  $path
-     * @return string
-     */
-    public function getExtension(string $path): string
+    private function getExtension(string $path): string
     {
         $parts = explode('.', $path);
 
diff --git a/app/Models/MicropubClient.php b/app/Models/MicropubClient.php
index 5d2b45e0..669c7284 100644
--- a/app/Models/MicropubClient.php
+++ b/app/Models/MicropubClient.php
@@ -4,30 +4,20 @@ declare(strict_types=1);
 
 namespace App\Models;
 
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 
 class MicropubClient extends Model
 {
-    /**
-     * The table associated with the model.
-     *
-     * @var string
-     */
+    use HasFactory;
+
+    /** @var string */
     protected $table = 'clients';
 
-    /**
-     * The attributes that are mass assignable.
-     *
-     * @var array
-     */
+    /** @var array */
     protected $fillable = ['client_url', 'client_name'];
 
-    /**
-     * Define the relationship with notes.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
-     */
     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 8ed7f9c1..74533443 100644
--- a/app/Models/Note.php
+++ b/app/Models/Note.php
@@ -4,29 +4,42 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use Cache;
-use Twitter;
-use Normalizer;
+use App\CommonMark\Generators\MentionGenerator;
+use App\CommonMark\Renderers\MentionRenderer;
+use Codebird\Codebird;
+use Exception;
 use GuzzleHttp\Client;
-use Laravel\Scout\Searchable;
-use League\CommonMark\Converter;
-use League\CommonMark\DocParser;
-use Jonnybarnes\IndieWeb\Numbers;
-use League\CommonMark\Environment;
-use League\CommonMark\HtmlRenderer;
-use Illuminate\Database\Eloquent\Model;
-use Jonnybarnes\EmojiA11y\EmojiModifier;
 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 Jonnybarnes\CommonmarkLinkify\LinkifyExtension;
+use Illuminate\Support\Facades\Cache;
+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;
 
 class Note extends Model
 {
+    use HasFactory;
     use Searchable;
     use SoftDeletes;
 
     /**
-     * The reges for matching lone usernames.
+     * The regex for matching lone usernames.
      *
      * @var string
      */
@@ -35,12 +48,10 @@ class Note extends Model
     /**
      * This variable is used to keep track of contacts in a note.
      */
-    protected $contacts;
+    protected ?array $contacts;
 
     /**
      * Set our contacts variable to null.
-     *
-     * @param  array  $attributes
      */
     public function __construct(array $attributes = [])
     {
@@ -48,85 +59,46 @@ class Note extends Model
         $this->contacts = null;
     }
 
-    /**
-     * The database table used by the model.
-     *
-     * @var string
-     */
+    /** @var string */
     protected $table = 'notes';
 
-    /*
-     * Mass-assignment
-     *
-     * @var array
-     */
+    /** @var array */
     protected $fillable = [
         'note',
         'in_reply_to',
         'client_id',
     ];
 
-    /**
-     * Hide the column used with Laravel Scout.
-     *
-     * @var array
-     */
+    /** @var array */
     protected $hidden = ['searchable'];
 
-    /**
-     * Define the relationship with tags.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
-     */
-    public function tags()
+    public function tags(): BelongsToMany
     {
-        return $this->belongsToMany('App\Models\Tag');
+        return $this->belongsToMany(Tag::class);
+    }
+
+    public function client(): BelongsTo
+    {
+        return $this->belongsTo(MicropubClient::class, 'client_id', 'client_url');
+    }
+
+    public function webmentions(): MorphMany
+    {
+        return $this->morphMany(WebMention::class, 'commentable');
+    }
+
+    public function place(): BelongsTo
+    {
+        return $this->belongsTo(Place::class);
+    }
+
+    public function media(): HasMany
+    {
+        return $this->hasMany(Media::class);
     }
 
     /**
-     * Define the relationship with clients.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
-     */
-    public function client()
-    {
-        return $this->belongsTo('App\Models\MicropubClient', 'client_id', 'client_url');
-    }
-
-    /**
-     * Define the relationship with webmentions.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
-     */
-    public function webmentions()
-    {
-        return $this->morphMany('App\Models\WebMention', 'commentable');
-    }
-
-    /**
-     * Define the relationship with places.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
-     */
-    public function place()
-    {
-        return $this->belongsTo('App\Models\Place');
-    }
-
-    /**
-     * Define the relationship with media.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
-     */
-    public function media()
-    {
-        return $this->hasMany('App\Models\Media');
-    }
-
-    /**
-     * Set the attributes to be indexed for searching with Scout.
-     *
-     * @return array
+     * @return array
      */
     public function toSearchableArray(): array
     {
@@ -135,16 +107,11 @@ class Note extends Model
         ];
     }
 
-    /**
-     * Normalize the note to Unicode FORM C.
-     *
-     * @param  string|null  $value
-     */
-    public function setNoteAttribute(?string $value)
+    public function setNoteAttribute(?string $value): void
     {
         if ($value !== null) {
             $normalized = normalizer_normalize($value, Normalizer::FORM_C);
-            if ($normalized === '') { //we don’t want to save empty strings to the db
+            if ($normalized === '') { // we don’t want to save empty strings to the db
                 $normalized = null;
             }
             $this->attributes['note'] = $normalized;
@@ -153,14 +120,11 @@ class Note extends Model
 
     /**
      * Pre-process notes for web-view.
-     *
-     * @param  string|null  $value
-     * @return string|null
      */
     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
@@ -168,34 +132,29 @@ class Note extends Model
             return null;
         }
 
-        $hcards = $this->makeHCards($value);
-        $hashtags = $this->autoLinkHashtag($hcards);
-        $html = $this->convertMarkdown($hashtags);
-        $modified = resolve(EmojiModifier::class)->makeEmojiAccessible($html);
+        $hashtags = $this->autoLinkHashtag($value);
 
-        return $modified;
+        return $this->convertMarkdown($hashtags);
     }
 
     /**
      * Provide the content_html for JSON feed.
      *
-     * In particular we want to include media links such as images.
-     *
-     * @return string
+     * In particular, we want to include media links such as images.
      */
     public function getContentAttribute(): string
     {
-        $note = $this->note;
+        $note = $this->getRawOriginal('note');
 
         foreach ($this->media as $media) {
-            if ($media->type == 'image') {
-                $note .= '';
+            if ($media->type === 'image') {
+                $note .= PHP_EOL . '';
             }
-            if ($media->type == 'audio') {
-                $note .= '