diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..7ccd9923 --- /dev/null +++ b/.babelrc @@ -0,0 +1,9 @@ +{ + "presets": [ + ["env", { + "targets": { + "browsers": ["last 2 versions", "safari >= 7"] + } + }] + ] +} diff --git a/.editorconfig b/.editorconfig index 0b5d680f..5a999757 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,21 +1,18 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file root = true +# Unix-style newlines with a newline ending every file [*] -charset = utf-8 end_of_line = lf -indent_size = 4 -indent_style = space +charset = utf-8 insert_final_newline = true trim_trailing_whitespace = true - -[*.{js,css}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false - -[*.{yml,yaml}] -indent_size = 2 - -[docker-compose.yml] +indent_style = space indent_size = 4 + +# Tab indentation +[Makefile] +indent_style = tab +tab_width = 4 diff --git a/.env.dusk.testing b/.env.dusk.testing new file mode 100644 index 00000000..756f4074 --- /dev/null +++ b/.env.dusk.testing @@ -0,0 +1,14 @@ +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 4eb61db5..21e860aa 100644 --- a/.env.example +++ b/.env.example @@ -1,90 +1,62 @@ APP_NAME=Laravel -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=https://example.com +APP_ENV=production +APP_KEY=SomeRandomString # Leave this +APP_DEBUG=false +APP_LOG_LEVEL=warning -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_CONNECTION=pgsql DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=laravel -DB_USERNAME=root +DB_PORT=5432 +DB_DATABASE= +DB_USERNAME= DB_PASSWORD= -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null +BROADCAST_DRIVER=log +CACHE_DRIVER=file +SESSION_DRIVER=file +QUEUE_DRIVER=sync -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_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 +MAIL_DRIVER=smtp +MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= -VITE_APP_NAME="${APP_NAME}" +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 -ADMIN_USER=admin# pick something better, this is used for `/admin` +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_PASS=password -DISPLAY_NAME='Joe Bloggs'# This is used for example in the header and titles +DISPLAY_NAME='Joe Bloggs' # This is used for example in the header and titles TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_SECRET= TWITTER_ACCESS_TOKEN= TWITTER_ACCESS_TOKEN_SECRET= -SCOUT_DRIVER=database -SCOUT_QUEUE=false +SCOUT_DRIVER=pgsql -SESSION_SECURE_COOKIE=true -SESSION_SAME_SITE=strict +PIWIK=false +PIWIK_ID=1 +PIWIK_URL=https://analytics.jmb.lv/piwik.php -LOG_SLACK_WEBHOOK_URL= - -FLARE_KEY= - -IGNITION_OPEN_AI_KEY= - -BRIDGY_MASTODON_TOKEN= +APP_TIMEZONE=UTC +APP_LANG=en +APP_LOG=daily +SECURE_SESSION_COOKIE=true diff --git a/.env.travis b/.env.travis new file mode 100644 index 00000000..766e9ed8 --- /dev/null +++ b/.env.travis @@ -0,0 +1,14 @@ +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 diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..b6ca2fd4 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,24 @@ +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 78f41d7a..967315dd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,5 @@ -* text=auto eol=lf - -*.blade.php diff=html -*.css diff=css -*.html diff=html -*.md diff=markdown -*.php diff=php +* text=auto +*.css linguist-vendored +*.scss linguist-vendored +*.js linguist-vendored +CHANGELOG.md export-ignore diff --git a/.gitignore b/.gitignore index a0c2459a..27dc57b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,16 @@ -/.phpunit.cache /node_modules -/public/build -/public/coverage /public/hot -/public/files /public/storage /storage/*.key /vendor -.env -.env.backup -.env.production -.phpunit.result.cache -Homestead.json +/.idea +/.vagrant Homestead.yaml -auth.json +Homestead.json npm-debug.log yarn-error.log -/.fleet -/.idea -/.vscode -ray.php -/public/gpg.key -/public/assets/img/favicon.png +.env +/public/files +/public/keybase.txt +/coverage +/LegacyTests diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 00000000..5e728eb1 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,9 @@ +preset: laravel + +disabled: + - concat_without_spaces + - simplified_null_return + - single_import_per_statement + +finder: + path: app/ diff --git a/.stylelintrc b/.stylelintrc index a9a9091b..6449c3f2 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,3 +1,6 @@ { - "extends": ["stylelint-config-standard"] + "extends": "stylelint-config-standard", + "rules": { + "indentation": 4 + } } diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..68e984a2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,73 @@ +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.1 + - 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-clover build/logs/clover.xml + - phpcs + #- php artisan dusk + - php vendor/bin/security-checker security:check --end-point=http://security.sensiolabs.org/check_lock + +after_success: + - travis_retry php vendor/bin/coveralls diff --git a/app/CommonMark/Generators/MentionGenerator.php b/app/CommonMark/Generators/MentionGenerator.php deleted file mode 100644 index 2ac1a797..00000000 --- a/app/CommonMark/Generators/MentionGenerator.php +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 2e8d2bce..00000000 --- a/app/Console/Commands/CopyMediaToLocal.php +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 8d5d2c92..00000000 --- a/app/Console/Commands/MigratePlaceDataFromPostgis.php +++ /dev/null @@ -1,75 +0,0 @@ -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 a6b29176..960a1b9f 100644 --- a/app/Console/Commands/ParseCachedWebMentions.php +++ b/app/Console/Commands/ParseCachedWebMentions.php @@ -1,12 +1,9 @@ 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); - $webmention = WebMention::where('source', $url)->firstOrFail(); + $url = $this->URLFromFilename($filepath); $microformats = \Mf2\parse($html, $url); + $webmention = WebMention::where('source', $url)->firstOrFail(); $webmention->mf2 = json_encode($microformats); $webmention->save(); $this->info('Saved the microformats to the database.'); @@ -50,13 +57,16 @@ class ParseCachedWebMentions extends Command /** * Determine the source URL from a filename. + * + * @param string + * @return string */ - private function urlFromFilename(string $filepath): string + private function URLFromFilename($filepath) { $dir = mb_substr($filepath, mb_strlen(storage_path() . '/HTML/')); $url = str_replace(['http/', 'https/'], ['http://', 'https://'], $dir); - if (mb_substr($url, -10) === 'index.html') { - $url = mb_substr($url, 0, -10); + if (mb_substr($url, -10) == 'index.html') { + $url = mb_substr($url, 0, mb_strlen($url) - 10); } return $url; diff --git a/app/Console/Commands/ReDownloadWebMentions.php b/app/Console/Commands/ReDownloadWebMentions.php index c6452ba9..6728284a 100644 --- a/app/Console/Commands/ReDownloadWebMentions.php +++ b/app/Console/Commands/ReDownloadWebMentions.php @@ -1,12 +1,10 @@ securityChecker = $securityChecker; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $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 deleted file mode 100644 index f5bc1114..00000000 --- a/app/Console/Commands/UpdateWebmentionsRelationship.php +++ /dev/null @@ -1,36 +0,0 @@ -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 432844ad..cec36b39 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,18 +8,33 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel; class Kernel extends ConsoleKernel { /** - * Define the application's command schedule. + * The Artisan commands provided by your application. + * + * @var array */ - protected function schedule(Schedule $schedule): void + 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) { $schedule->command('horizon:snapshot')->everyFiveMinutes(); - $schedule->command('cache:prune-stale-tags')->hourly(); } /** * Register the commands for the application. + * + * @return void */ - protected function commands(): void + protected function commands() { $this->load(__DIR__.'/Commands'); diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index cb48444a..971523ba 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,18 +2,61 @@ 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 { /** - * Register the exception handling callbacks for the application. + * A list of the exception types that are not reported. + * + * @var array */ - public function register(): void + 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) { - $this->reportable(function (Throwable $_e) { - // - }); + 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); } } diff --git a/app/Exceptions/InternetArchiveException.php b/app/Exceptions/InternetArchiveException.php index 99d5cab7..7e810fea 100644 --- a/app/Exceptions/InternetArchiveException.php +++ b/app/Exceptions/InternetArchiveException.php @@ -2,4 +2,6 @@ namespace App\Exceptions; -class InternetArchiveException extends \Exception {} +class InternetArchiveException extends \Exception +{ +} diff --git a/app/Exceptions/InvalidTokenException.php b/app/Exceptions/InvalidTokenException.php new file mode 100644 index 00000000..8184cfa7 --- /dev/null +++ b/app/Exceptions/InvalidTokenException.php @@ -0,0 +1,13 @@ +orderBy('id', 'desc')->get(); return view('admin.articles.index', ['posts' => $posts]); } - public function create(): View + /** + * Show the new article form. + * + * @return \Illuminate\View\Factory view + */ + public function create() { $message = session('message'); return view('admin.articles.create', ['message' => $message]); } - public function store(): RedirectResponse + /** + * Process an incoming request for a new article and save it. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\View\Factory view + */ + public function store(Request $request) { - // if a `.md` is attached use that for the main content. - if (request()->hasFile('article')) { - $file = request()->file('article')->openFile(); + //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::create([ - 'url' => request()->input('url'), - 'title' => request()->input('title'), - 'main' => $main, - 'published' => request()->input('published') ?? 0, - ]); + $main = $content ?? $request->input('main'); + $article = Article::create( + [ + 'url' => $request->input('url'), + 'title' => $request->input('title'), + 'main' => $main, + 'published' => $request->input('published') ?? 0, + ] + ); return redirect('/admin/blog'); } - public function edit(Article $article): View + /** + * Show the edit form for an existing article. + * + * @param string The article id + * @return \Illuminate\View\Factory view + */ + public function edit($articleId) { - return view('admin.articles.edit', ['article' => $article]); + $post = Article::select( + 'title', + 'main', + 'url', + 'published' + )->where('id', $articleId)->get(); + + return view('admin.articles.edit', ['id' => $articleId, 'post' => $post]); } - public function update(int $articleId): RedirectResponse + /** + * Process an incoming request to edit an article. + * + * @param \Illuminate\Http\Request $request + * @param string + * @return \Illuminate|View\Factory view + */ + public function update(Request $request, $articleId) { $article = Article::find($articleId); - $article->title = request()->input('title'); - $article->url = request()->input('url'); - $article->main = request()->input('main'); - $article->published = request()->input('published') ?? 0; + $article->title = $request->input('title'); + $article->url = $request->input('url'); + $article->main = $request->input('main'); + $article->published = $request->input('published') ?? 0; $article->save(); return redirect('/admin/blog'); } - public function destroy(int $articleId): RedirectResponse + /** + * Process a request to delete an aricle. + * + * @param string The article id + * @return \Illuminate\View\Factory view + */ + public function destroy($articleId) { Article::where('id', $articleId)->delete(); diff --git a/app/Http/Controllers/Admin/BioController.php b/app/Http/Controllers/Admin/BioController.php deleted file mode 100644 index c760e12c..00000000 --- a/app/Http/Controllers/Admin/BioController.php +++ /dev/null @@ -1,32 +0,0 @@ - $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 38524b62..366e0994 100644 --- a/app/Http/Controllers/Admin/ClientsController.php +++ b/app/Http/Controllers/Admin/ClientsController.php @@ -1,20 +1,19 @@ request()->input('client_url'), - 'client_name' => request()->input('client_name'), + 'client_url' => $request->input('client_url'), + 'client_name' => $request->input('client_name'), ]); return redirect('/admin/clients'); @@ -44,8 +48,11 @@ class ClientsController extends Controller /** * Show a form to edit a client name. + * + * @param string The client id + * @return \Illuminate\View\Factory view */ - public function edit(int $clientId): View + public function edit($clientId) { $client = MicropubClient::findOrFail($clientId); @@ -58,12 +65,16 @@ class ClientsController extends Controller /** * Process the request to edit a client name. + * + * @param string The client id + * @param \Illuminate\Http\Request $request + * @return \Illuminate\View\Factory view */ - public function update(int $clientId): RedirectResponse + public function update($clientId, Request $request) { $client = MicropubClient::findOrFail($clientId); - $client->client_url = request()->input('client_url'); - $client->client_name = request()->input('client_name'); + $client->client_url = $request->input('client_url'); + $client->client_name = $request->input('client_name'); $client->save(); return redirect('/admin/clients'); @@ -71,8 +82,11 @@ class ClientsController extends Controller /** * Process a request to delete a client. + * + * @param string The client id + * @return redirect */ - public function destroy(int $clientId): RedirectResponse + public function destroy($clientId) { MicropubClient::where('id', $clientId)->delete(); diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php index eb45320c..82ec7e88 100644 --- a/app/Http/Controllers/Admin/ContactsController.php +++ b/app/Http/Controllers/Admin/ContactsController.php @@ -1,23 +1,21 @@ name = request()->input('name'); - $contact->nick = request()->input('nick'); - $contact->homepage = request()->input('homepage'); - $contact->twitter = request()->input('twitter'); - $contact->facebook = request()->input('facebook'); + $contact = new Contact(); + $contact->name = $request->input('name'); + $contact->nick = $request->input('nick'); + $contact->homepage = $request->input('homepage'); + $contact->twitter = $request->input('twitter'); + $contact->facebook = $request->input('facebook'); $contact->save(); return redirect('/admin/contacts'); @@ -50,8 +53,11 @@ class ContactsController extends Controller /** * Show the form to edit an existing contact. + * + * @param string The contact id + * @return \Illuminate\View\Factory view */ - public function edit(int $contactId): View + public function edit($contactId) { $contact = Contact::findOrFail($contactId); @@ -62,25 +68,29 @@ class ContactsController extends Controller * Process the request to edit a contact. * * @todo Allow saving profile pictures for people without homepages + * + * @param string The contact id + * @param \Illuminate\Http\Request $request + * @return \Illuminate\View\Factory view */ - public function update(int $contactId): RedirectResponse + public function update($contactId, Request $request) { $contact = Contact::findOrFail($contactId); - $contact->name = request()->input('name'); - $contact->nick = request()->input('nick'); - $contact->homepage = request()->input('homepage'); - $contact->twitter = request()->input('twitter'); - $contact->facebook = request()->input('facebook'); + $contact->name = $request->input('name'); + $contact->nick = $request->input('nick'); + $contact->homepage = $request->input('homepage'); + $contact->twitter = $request->input('twitter'); + $contact->facebook = $request->input('facebook'); $contact->save(); - if (request()->hasFile('avatar') && (request()->input('homepage') != '')) { - $dir = parse_url(request()->input('homepage'), PHP_URL_HOST); + 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); } - request()->file('avatar')->move($destination, 'image'); + $request->file('avatar')->move($destination, 'image'); } return redirect('/admin/contacts'); @@ -88,8 +98,11 @@ class ContactsController extends Controller /** * Process the request to delete a contact. + * + * @param string The contact id + * @return \Illuminate\View\Factory view */ - public function destroy(int $contactId): RedirectResponse + public function destroy($contactId) { $contact = Contact::findOrFail($contactId); $contact->delete(); @@ -103,15 +116,16 @@ class ContactsController extends Controller * This method attempts to find the microformat marked-up profile image * from a given homepage and save it accordingly * - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View + * @param string The contact id + * @return \Illuminate\View\Factory view */ - public function getAvatar(int $contactId) + public function getAvatar($contactId) { // Initialising $avatarURL = null; $avatar = null; $contact = Contact::findOrFail($contactId); - if ($contact->homepage !== null && mb_strlen($contact->homepage) !== 0) { + if (mb_strlen($contact->homepage !== null) !== 0) { $client = resolve(Client::class); try { $response = $client->get($contact->homepage); @@ -121,8 +135,8 @@ class ContactsController extends Controller } $mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage); foreach ($mf2['items'] as $microformat) { - if (Arr::get($microformat, 'type.0') === 'h-card') { - $avatarURL = Arr::get($microformat, 'properties.photo.0.value'); + if (array_get($microformat, 'type.0') == 'h-card') { + $avatarURL = array_get($microformat, 'properties.photo.0'); break; } } @@ -136,7 +150,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 ae4f4d36..ebb06f31 100644 --- a/app/Http/Controllers/Admin/HomeController.php +++ b/app/Http/Controllers/Admin/HomeController.php @@ -1,18 +1,12 @@ config('admin.user')]); } diff --git a/app/Http/Controllers/Admin/LikesController.php b/app/Http/Controllers/Admin/LikesController.php deleted file mode 100644 index 9ebd7e74..00000000 --- a/app/Http/Controllers/Admin/LikesController.php +++ /dev/null @@ -1,81 +0,0 @@ - normalize_url(request()->input('like_url')), - ]); - ProcessLike::dispatch($like); - - return redirect('/admin/likes'); - } - - /** - * Display the form to edit a specific like. - */ - public function edit(int $likeId): View - { - $like = Like::findOrFail($likeId); - - return view('admin.likes.edit', [ - 'id' => $like->id, - 'like_url' => $like->url, - ]); - } - - /** - * Process a request to edit a like. - */ - public function update(int $likeId): RedirectResponse - { - $like = Like::findOrFail($likeId); - $like->url = normalize_url(request()->input('like_url')); - $like->save(); - ProcessLike::dispatch($like); - - return redirect('/admin/likes'); - } - - /** - * Process the request to delete a like. - */ - public function destroy(int $likeId): RedirectResponse - { - Like::where('id', $likeId)->delete(); - - return redirect('/admin/likes'); - } -} diff --git a/app/Http/Controllers/Admin/NotesController.php b/app/Http/Controllers/Admin/NotesController.php index c6ed93ba..44b4a30f 100644 --- a/app/Http/Controllers/Admin/NotesController.php +++ b/app/Http/Controllers/Admin/NotesController.php @@ -1,22 +1,20 @@ orderBy('id', 'desc')->get(); foreach ($notes as $note) { @@ -28,19 +26,24 @@ class NotesController extends Controller /** * Show the form to make a new note. + * + * @return \Illuminate\View\Factory view */ - public function create(): View + public function create() { return view('admin.notes.create'); } /** * Process a request to make a new note. + * + * @param Illuminate\Http\Request $request + * @todo Sort this mess out */ - public function store(Request $request): RedirectResponse + public function store(Request $request) { Note::create([ - 'in_reply_to' => $request->input('in-reply-to'), + 'in-reply-to' => $request->input('in-reply-to'), 'note' => $request->input('content'), ]); @@ -49,8 +52,11 @@ class NotesController extends Controller /** * Display the form to edit a specific note. + * + * @param string The note id + * @return \Illuminate\View\Factory view */ - public function edit(int $noteId): View + public function edit($noteId) { $note = Note::find($noteId); $note->originalNote = $note->getOriginal('note'); @@ -61,16 +67,19 @@ class NotesController extends Controller /** * Process a request to edit a note. Easy since this can only be done * from the admin CP. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\View\Factory view */ - public function update(int $noteId): RedirectResponse + public function update($noteId, Request $request) { - // update note data + //update note data $note = Note::findOrFail($noteId); - $note->note = request()->input('content'); - $note->in_reply_to = request()->input('in-reply-to'); + $note->note = $request->input('content'); + $note->in_reply_to = $request->input('in-reply-to'); $note->save(); - if (request()->input('webmentions')) { + if ($request->input('webmentions')) { dispatch(new SendWebMentions($note)); } @@ -79,10 +88,13 @@ class NotesController extends Controller /** * Delete the note. + * + * @param int id + * @return view */ - public function destroy(int $noteId): RedirectResponse + public function destroy($id) { - $note = Note::findOrFail($noteId); + $note = Note::findOrFail($id); $note->delete(); return redirect('/admin/notes'); diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php deleted file mode 100644 index 9f635f10..00000000 --- a/app/Http/Controllers/Admin/PasskeysController.php +++ /dev/null @@ -1,326 +0,0 @@ -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 e5e82bcd..4bf54f4a 100644 --- a/app/Http/Controllers/Admin/PlacesController.php +++ b/app/Http/Controllers/Admin/PlacesController.php @@ -1,18 +1,16 @@ placeService->createPlace( - request()->only([ - 'name', - 'description', - 'latitude', - 'longitude', - ]) - ); + $data = $request->only(['name', 'description', 'latitude', 'longitude']); + $place = $this->placeService->createPlace($data); return redirect('/admin/places'); } /** * Display the form to edit a specific place. + * + * @param string The place id + * @return \Illuminate\View\Factory view */ - public function edit(int $placeId): View + public function edit($placeId) { $place = Place::findOrFail($placeId); @@ -66,15 +68,18 @@ class PlacesController extends Controller /** * Process a request to edit a place. + * + * @param string The place id + * @param Illuminate\Http\Request $request + * @return Illuminate\View\Factory view */ - public function update(int $placeId): RedirectResponse + public function update($placeId, Request $request) { $place = Place::findOrFail($placeId); - $place->name = request()->input('name'); - $place->description = request()->input('description'); - $place->latitude = request()->input('latitude'); - $place->longitude = request()->input('longitude'); - $place->icon = request()->input('icon'); + $place->name = $request->name; + $place->description = $request->description; + $place->location = new Point((float) $request->latitude, (float) $request->longitude); + $place->icon = $request->icon; $place->save(); return redirect('/admin/places'); @@ -82,11 +87,14 @@ class PlacesController extends Controller /** * List the places we can merge with the current place. + * + * @param string Place id + * @return Illuminate\View\Factory view */ - public function mergeIndex(int $placeId): View + public function mergeIndex($placeId) { $first = Place::find($placeId); - $results = Place::near((object) ['latitude' => $first->latitude, 'longitude' => $first->longitude])->get(); + $results = Place::near(new Point($first->latitude, $first->longitude))->get(); $places = []; foreach ($results as $place) { if ($place->slug !== $first->slug) { @@ -97,33 +105,27 @@ class PlacesController extends Controller return view('admin.places.merge.index', compact('first', 'places')); } - /** - * Show a form for merging two specific places. - */ - public function mergeEdit(int $placeId1, int $placeId2): View + public function mergeEdit($place1_id, $place2_id) { - $place1 = Place::find($placeId1); - $place2 = Place::find($placeId2); + $place1 = Place::find($place1_id); + $place2 = Place::find($place2_id); return view('admin.places.merge.edit', compact('place1', 'place2')); } - /** - * Process the request to merge two places. - */ - public function mergeStore(): RedirectResponse + public function mergeStore(Request $request) { - $place1 = Place::find(request()->input('place1')); - $place2 = Place::find(request()->input('place2')); + $place1 = Place::find($request->input('place1')); + $place2 = Place::find($request->input('place2')); - if (request()->input('delete') === '1') { + if ($request->input('delete') === '1') { foreach ($place1->notes as $note) { $note->place()->dissociate(); $note->place()->associate($place2->id); } $place1->delete(); } - if (request()->input('delete') === '2') { + if ($request->input('delete') === '2') { foreach ($place2->notes as $note) { $note->place()->dissociate(); $note->place()->associate($place1->id); diff --git a/app/Http/Controllers/Admin/SyndicationTargetsController.php b/app/Http/Controllers/Admin/SyndicationTargetsController.php deleted file mode 100644 index dc14a2d2..00000000 --- a/app/Http/Controllers/Admin/SyndicationTargetsController.php +++ /dev/null @@ -1,94 +0,0 @@ -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 9ab860d7..a778c5bc 100644 --- a/app/Http/Controllers/ArticlesController.php +++ b/app/Http/Controllers/ArticlesController.php @@ -1,58 +1,52 @@ date($year, $month) - ->orderBy('updated_at', 'desc') - ->simplePaginate(5); + ->date((int) $year, (int) $month) + ->orderBy('updated_at', 'desc') + ->simplePaginate(5); return view('articles.index', compact('articles')); } /** * Show a single article. + * + * @return \Illuminate\View\Factory view */ - public function show(int $year, int $month, string $slug): RedirectResponse|View + public function show($year, $month, $slug) { - try { - $article = Article::where('titleurl', $slug)->firstOrFail(); - } catch (ModelNotFoundException $exception) { - abort(404); - } - + $article = Article::where('titleurl', $slug)->firstOrFail(); if ($article->updated_at->year != $year || $article->updated_at->month != $month) { - return redirect('/blog/' - . $article->updated_at->year - . '/' . $article->updated_at->format('m') - . '/' . $slug); + return redirect('/blog/' . $article->updated_at->year . '/' . $article->updated_at->month .'/' . $slug); } return view('articles.show', compact('article')); } /** - * We only have the ID, work out post title, year and month and redirect to it. + * We only have the ID, work out post title, year and month + * and redirect to it. + * + * @return \Illuminte\Routing\RedirectResponse redirect */ - public function onlyIdInUrl(string $idFromUrl): RedirectResponse + public function onlyIdInUrl($inURLId) { - $realId = resolve(Numbers::class)->b60tonum($idFromUrl); - + $numbers = new Numbers(); + $realId = $numbers->b60tonum($inURLId); $article = Article::findOrFail($realId); return redirect($article->link); diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index bd0022d6..b58ed184 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -1,62 +1,34 @@ only('name', 'password'); + if ($request->input('username') === config('admin.user') + && + $request->input('password') === config('admin.pass') + ) { + session(['loggedin' => true]); - if (Auth::attempt($credentials, true)) { - return redirect()->intended('/admin'); + 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 b4bb3c13..a19bdeb6 100644 --- a/app/Http/Controllers/BookmarksController.php +++ b/app/Http/Controllers/BookmarksController.php @@ -1,43 +1,22 @@ with('tags')->withCount('tags')->paginate(10); return view('bookmarks.index', compact('bookmarks')); } - /** - * Show a single bookmark. - */ - public function show(Bookmark $bookmark): View + public function show(Bookmark $bookmark) { $bookmark->loadMissing('tags'); 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 280cc3ed..c85e12a4 100644 --- a/app/Http/Controllers/ContactsController.php +++ b/app/Http/Controllers/ContactsController.php @@ -1,21 +1,20 @@ homepageHost = parse_url($contact->homepage, PHP_URL_HOST); @@ -31,13 +30,15 @@ class ContactsController extends Controller /** * Show a single contact. + * + * @return \Illuminate\View\Factory view */ - public function show(Contact $contact): View + public function show($nick) { + $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 8677cd5c..03e02a23 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,12 @@ namespace App\Http\Controllers; -abstract class Controller +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 { - // + use AuthorizesRequests, DispatchesJobs, ValidatesRequests; } diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index eb0847a3..2d6da628 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -1,85 +1,82 @@ latest('updated_at')->take(20)->get(); $buildDate = $articles->first()->updated_at->toRssString(); return response() - ->view('articles.rss', compact('articles', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->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 + public function blogAtom() { $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 + public function notesRss() { $notes = Note::latest()->take(20)->get(); $buildDate = $notes->first()->updated_at->toRssString(); return response() - ->view('notes.rss', compact('notes', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->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 + public function notesAtom() { $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\response */ - public function blogJson(): array + public function blogJson() { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1.1', - 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s blog', + 'version' => 'https://jsonfeed.org/version/1', + 'title' => 'The JSON Feed for ' . config('app.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' => [], ]; @@ -91,6 +88,9 @@ 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'), + ], ]; } @@ -99,109 +99,34 @@ class FeedsController extends Controller /** * Returns the notes JSON feed. + * + * @return \Illuminate\Http\response */ - public function notesJson(): array + public function notesJson() { - $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); + $notes = Note::latest()->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1.1', - 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s notes', + 'version' => 'https://jsonfeed.org/version/1', + 'title' => 'The JSON Feed for ' . config('app.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->uri, - 'url' => $note->uri, - 'content_text' => $note->content, + 'id' => $note->longurl, + 'title' => $note->getOriginal('note'), + 'url' => $note->longurl, + 'content_html' => $note->note, '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 deleted file mode 100644 index 19537663..00000000 --- a/app/Http/Controllers/FrontPageController.php +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 45b488da..00000000 --- a/app/Http/Controllers/IndieAuthController.php +++ /dev/null @@ -1,327 +0,0 @@ -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 af1c483c..ea9e503b 100644 --- a/app/Http/Controllers/LikesController.php +++ b/app/Http/Controllers/LikesController.php @@ -1,28 +1,19 @@ paginate(20); return view('likes.index', compact('likes')); } - /** - * Show a single like. - */ - public function show(Like $like): View + public function show(Like $like) { return view('likes.show', compact('like')); } diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 758b3255..a817526f 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -1,112 +1,136 @@ handlerRegistry = $handlerRegistry; + public function __construct( + TokenService $tokenService, + HEntryService $hentryService, + HCardService $hcardService, + UpdateService $updateService + ) { + $this->tokenService = $tokenService; + $this->hentryService = $hentryService; + $this->hcardService = $hcardService; + $this->updateService = $updateService; } /** - * Respond to a POST request to the micropub endpoint. + * This function receives an API request, verifies the authenticity + * then passes over the info to the relavent Service class. * - * 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. + * @return \Illuminate\Http\Response */ - public function post(MicropubRequest $request): JsonResponse + public function post() { - $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 { - $handler = $this->handlerRegistry->getHandler($type); - $result = $handler->handle($request->getMicropubData()); - - // Return appropriate response based on the handler result - return response()->json([ - 'response' => $result['response'], - 'location' => $result['url'] ?? null, - ], 201)->header('Location', $result['url']); - } catch (\InvalidArgumentException $e) { - return response()->json([ - '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); + $tokenData = $this->tokenService->validateToken(request()->bearerToken()); + } 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()); + + 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()); + + return response()->json([ + 'response' => 'created', + 'location' => $location, + ], 201)->header('Location', $location); + } + + 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 whether the token is valid and respond + * token, here we check wether the token is valid and respond * appropriately. Further if the request has the query parameter - * syndicate-to we respond with the known syndication endpoints. + * synidicate-to we respond with the known syndication endpoints. + * + * @return \Illuminate\Http\Response */ - public function get(Request $request): JsonResponse + public function get() { - if ($request->input('q') === 'syndicate-to') { + try { + $tokenData = $this->tokenService->validateToken(request()->bearerToken()); + } catch (InvalidTokenException $e) { + return $this->invalidTokenResponse(); + } + + if (request()->input('q') === 'syndicate-to') { return response()->json([ - 'syndicate-to' => SyndicationTarget::all(), + 'syndicate-to' => config('syndication.targets'), ]); } - if ($request->input('q') === 'config') { + if (request()->input('q') == 'config') { return response()->json([ - 'syndicate-to' => SyndicationTarget::all(), + 'syndicate-to' => config('syndication.targets'), 'media-endpoint' => route('media-endpoint'), ]); } - if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) { + if (substr(request()->input('q'), 0, 4) === '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( - (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], - $distance - )->get(); + $distance = (count($matches[0]) == 3) ? 100 * $matches[0][2] : 1000; + $places = Place::near(new Point($matches[0][0], $matches[0][1]))->get(); return response()->json([ 'response' => 'places', @@ -114,17 +138,165 @@ class MicropubController extends Controller ]); } - // the default response is just to return the token data - /** @var Token $tokenData */ - $tokenData = $request->input('token_data'); - + // default response is just to return the token data return response()->json([ 'response' => 'token', 'token' => [ - 'me' => $tokenData['me'], - 'scope' => $tokenData['scope'], - 'client_id' => $tokenData['client_id'], + 'me' => $tokenData->getClaim('me'), + 'scope' => $tokenData->getClaim('scope'), + 'client_id' => $tokenData->getClaim('client_id'), ], ]); } + + /** + * Process a media item posted to the media endpoint. + * + * @return Illuminate\Http\Response + */ + public function media() + { + try { + $tokenData = $this->tokenService->validateToken(request()->bearerToken()); + } 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); + } + + /** + * Get the file type from the mimetype of the uploaded file. + * + * @param string The mimetype + * @return string The type + */ + private function getFileTypeFromMimeType($mimetype) + { + //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'; + } + + private function getClientId(): string + { + return resolve(TokenService::class) + ->validateToken(request()->bearerToken()) + ->getClaim('client_id'); + } + + private function logMicropubRequest(array $request) + { + $logger = new Logger('micropub'); + $logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')), Logger::DEBUG); + $logger->debug('MicropubLog', $request); + } + + private function saveFile(UploadedFile $file) + { + $filename = Uuid::uuid4() . '.' . $file->extension(); + Storage::disk('local')->put($filename, $file); + + return $filename; + } + + private function insufficientScopeResponse() + { + return response()->json([ + 'response' => 'error', + 'error' => 'insufficient_scope', + 'error_description' => 'The token’s scope does not have the necessary requirements.', + ], 401); + } + + private function invalidTokenResponse() + { + return response()->json([ + 'response' => 'error', + 'error' => 'invalid_token', + 'error_description' => 'The provided token did not pass validation', + ], 400); + } + + 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 deleted file mode 100644 index fc804ea2..00000000 --- a/app/Http/Controllers/MicropubMediaController.php +++ /dev/null @@ -1,201 +0,0 @@ -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 d5c9bc90..06ab3eb8 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -1,37 +1,31 @@ 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')); @@ -39,22 +33,16 @@ class NotesController extends Controller /** * Show a single note. + * + * @param string The id of the note + * @return \Illuminate\View\Factory view */ - public function show(string $urlId): View|JsonResponse|Response + public function show($urlId) { - 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); + $note = Note::nb60($urlId)->with('webmentions')->firstOrFail(); + + if (request()->wantsActivityStream()) { + return (new ActivityStreamsService)->singleNoteResponse($note); } return view('notes.show', compact('note')); @@ -62,16 +50,22 @@ class NotesController extends Controller /** * Redirect /note/{decID} to /notes/{nb60id}. + * + * @param string The decimal id of he note + * @return \Illuminate\Routing\RedirectResponse redirect */ - public function redirect(int $decId): RedirectResponse + public function redirect($decId) { - 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 The tag + * @return \Illuminate\View\Factory view */ - public function tagged(string $tag): View + public function tagged($tag) { $notes = Note::whereHas('tags', function ($query) use ($tag) { $query->where('tag', $tag); @@ -79,14 +73,4 @@ 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 b949ecde..37ee61f7 100644 --- a/app/Http/Controllers/PlacesController.php +++ b/app/Http/Controllers/PlacesController.php @@ -1,18 +1,17 @@ firstOrFail(); + return view('singleplace', ['place' => $place]); } } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 3f366538..6ef9af6c 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -4,31 +4,13 @@ namespace App\Http\Controllers; use App\Models\Note; use Illuminate\Http\Request; -use Illuminate\View\View; class SearchController extends Controller { - public function search(Request $request): View + public function search(Request $request) { - $search = $request->input('q'); + $notes = Note::search($request->terms)->paginate(10); - $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')); + return view('search', compact('notes')); } } diff --git a/app/Http/Controllers/SessionStoreController.php b/app/Http/Controllers/SessionStoreController.php new file mode 100644 index 00000000..c7d239eb --- /dev/null +++ b/app/Http/Controllers/SessionStoreController.php @@ -0,0 +1,15 @@ +input('css'); + + session(['css' => $css]); + + return ['status' => 'ok']; + } +} diff --git a/app/Http/Controllers/ShortURLsController.php b/app/Http/Controllers/ShortURLsController.php new file mode 100644 index 00000000..a4979ca0 --- /dev/null +++ b/app/Http/Controllers/ShortURLsController.php @@ -0,0 +1,65 @@ +client = $client; + $this->tokenService = $tokenService; + } + + /** + * If the user has auth’d via the IndieAuth protocol, issue a valid token. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function create(Request $request) + { + $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 = http_build_query([ + 'me' => $request->input('me'), + 'scope' => $scope, + 'access_token' => $token, + ]); + + return response($content)->header( + 'Content-Type', + 'application/x-www-form-urlencoded' + ); + } + + return response('There was an error verifying the authorisation code.', 400); + } + + return response('Can’t determine the authorisation endpoint.', 400); + } +} diff --git a/app/Http/Controllers/WebMentionsController.php b/app/Http/Controllers/WebMentionsController.php index 49eac9b2..b9110f56 100644 --- a/app/Http/Controllers/WebMentionsController.php +++ b/app/Http/Controllers/WebMentionsController.php @@ -1,70 +1,65 @@ has('target') !== true) || ($request->has('source') !== true)) { - return response( + return new Response( 'You need both the target and source parameters', 400 ); } - // next check the $target is valid + //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]; + $numbers = new Numbers(); try { - $note = Note::findOrFail(resolve(Numbers::class)->b60tonum($noteId)); + $note = Note::findOrFail($numbers->b60tonum($noteId)); dispatch(new ProcessWebMention($note, $request->input('source'))); } catch (ModelNotFoundException $e) { - return response('This note doesn’t exist.', 400); + return new Response('This note doesn’t exist.', 400); } - return response( + return new Response( 'Webmention received, it will be processed shortly', 202 ); } - if ($pathParts[1] === 'blog') { - return response( + if ($pathParts[1] == 'blog') { + return new Response( 'I don’t accept webmentions for blog posts yet.', 501 ); } - return response( + return new Response( 'Invalid request', 400 ); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php new file mode 100644 index 00000000..005c0d69 --- /dev/null +++ b/app/Http/Kernel.php @@ -0,0 +1,66 @@ + [ + \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, + ], + + '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, + ]; +} diff --git a/app/Http/Middleware/ActivityStreamLinks.php b/app/Http/Middleware/ActivityStreamLinks.php new file mode 100644 index 00000000..4c240759 --- /dev/null +++ b/app/Http/Middleware/ActivityStreamLinks.php @@ -0,0 +1,28 @@ +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 deleted file mode 100644 index 624cd371..00000000 --- a/app/Http/Middleware/Authenticate.php +++ /dev/null @@ -1,20 +0,0 @@ -expectsJson() ? null : route('login'); - } -} diff --git a/app/Http/Middleware/CorsHeaders.php b/app/Http/Middleware/CorsHeaders.php deleted file mode 100644 index cacf9188..00000000 --- a/app/Http/Middleware/CorsHeaders.php +++ /dev/null @@ -1,29 +0,0 @@ -path() === 'api/media') { - $response->header('Access-Control-Allow-Origin', '*'); - $response->header('Access-Control-Allow-Methods', 'OPTIONS, POST'); - $response->header( - 'Access-Control-Allow-Headers', - 'Authorization, Content-Type, DNT, X-CSRF-TOKEN, X-REQUESTED-WITH' - ); - $response->header('Access-Control-Allow-Credentials', 'true'); - } - - return $response; - } -} diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php index 867695bd..033136ad 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 467283db..66dee526 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -3,22 +3,23 @@ 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 $request, Closure $next): Response + public function handle($request, Closure $next) { $response = $next($request); - $response->header('Link', '<' . route('indieauth.metadata') . '>; rel="indieauth-metadata"', false); - $response->header('Link', '<' . route('indieauth.start') . '>; rel="authorization_endpoint"', false); - $response->header('Link', '<' . route('indieauth.token') . '>; rel="token_endpoint"', false); - $response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false); - $response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false); + $response->header('Link', '; 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); return $response; } diff --git a/app/Http/Middleware/LocalhostSessionMiddleware.php b/app/Http/Middleware/LocalhostSessionMiddleware.php index 060682d5..ded4f25a 100644 --- a/app/Http/Middleware/LocalhostSessionMiddleware.php +++ b/app/Http/Middleware/LocalhostSessionMiddleware.php @@ -1,12 +1,8 @@ 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): Response + public function handle($request, Closure $next) { if (config('app.env') !== 'production') { session(['me' => config('app.url')]); diff --git a/app/Http/Middleware/LogMicropubRequest.php b/app/Http/Middleware/LogMicropubRequest.php deleted file mode 100644 index a04e80de..00000000 --- a/app/Http/Middleware/LogMicropubRequest.php +++ /dev/null @@ -1,24 +0,0 @@ -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 b22e2b33..5354e55b 100644 --- a/app/Http/Middleware/MyAuthMiddleware.php +++ b/app/Http/Middleware/MyAuthMiddleware.php @@ -1,25 +1,22 @@ setIntendedUrl($request->fullUrl()); - + if ($request->session()->has('loggedin') !== true) { + //they’re not logged in, so send them to login form return redirect()->route('login'); } diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php deleted file mode 100644 index 74cbd9a9..00000000 --- a/app/Http/Middleware/PreventRequestsDuringMaintenance.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - protected $except = [ - // - ]; -} diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index a6a6c8c4..92c2fff8 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -2,11 +2,8 @@ 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 @@ -16,16 +13,15 @@ class RedirectIfAuthenticated /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string|null $guard + * @return mixed */ - public function handle(Request $request, Closure $next, string ...$guards): Response + public function handle($request, Closure $next, $guard = null) { - $guards = empty($guards) ? [null] : $guards; - - foreach ($guards as $guard) { - if (Auth::guard($guard)->check()) { - return redirect(RouteServiceProvider::HOME); - } + if (Auth::guard($guard)->check()) { + return redirect('/home'); } return $next($request); diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php index 88cadcaa..5a50e7b5 100644 --- a/app/Http/Middleware/TrimStrings.php +++ b/app/Http/Middleware/TrimStrings.php @@ -9,10 +9,9 @@ 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 deleted file mode 100644 index 9c88c34c..00000000 --- a/app/Http/Middleware/TrustHosts.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ - public function hosts(): array - { - return [ - $this->allSubdomainsOfApplicationUrl(), - ]; - } -} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index f33f3eef..ef1c00d1 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -2,27 +2,28 @@ 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|string|null + * @var array */ protected $proxies; /** - * The header that should be used to detect proxies. + * The current proxy header mappings. * - * @var int + * @var array */ - 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; + protected $headers = [ + Request::HEADER_FORWARDED => 'FORWARDED', + Request::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR', + Request::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST', + Request::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT', + Request::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO', + ]; } diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php deleted file mode 100644 index 093bf64a..00000000 --- a/app/Http/Middleware/ValidateSignature.php +++ /dev/null @@ -1,22 +0,0 @@ - - */ - 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 fc7bad50..1593e373 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 33d2cb12..93e2edf6 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -1,37 +1,21 @@ input('access_token')) { - $rawToken = $request->input('access_token'); - } elseif ($request->bearerToken()) { - $rawToken = $request->bearerToken(); - } - - if (! $rawToken) { + if ($request->bearerToken() === null) { return response()->json([ 'response' => 'error', 'error' => 'unauthorized', @@ -39,43 +23,6 @@ class VerifyMicropubToken ], 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; + return $next($request); } } diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php deleted file mode 100644 index d931f139..00000000 --- a/app/Http/Requests/MicropubRequest.php +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 4f7240c2..00000000 --- a/app/Http/Responses/MicropubResponses.php +++ /dev/null @@ -1,46 +0,0 @@ -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 b540aac0..89e2cae5 100644 --- a/app/Jobs/AddClientToDatabase.php +++ b/app/Jobs/AddClientToDatabase.php @@ -1,40 +1,39 @@ client_id = $clientId; + $this->client_id = $client_id; } /** * Execute the job. + * + * @return void */ - public function handle(): void + public function handle() { - if (MicropubClient::where('client_url', $this->client_id)->count() === 0) { - MicropubClient::create([ + if (MicropubClient::where('client_url', $this->client_id)->count() == 0) { + $client = 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 3c187dd4..9fe80f7e 100644 --- a/app/Jobs/DownloadWebMention.php +++ b/app/Jobs/DownloadWebMention.php @@ -1,66 +1,68 @@ source = $source; + } /** * Execute the job. * - * @throws GuzzleException - * @throws FileNotFoundException + * @return void */ - public function handle(Client $guzzle): void + public function handle(Client $guzzle) { $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 \Illuminate\FileSystem\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); } } @@ -68,12 +70,16 @@ class DownloadWebMention implements ShouldQueue } /** - * Create a file path from a URL. This is used when caching the HTML response. + * Create a file path from a URL. This is used when caching the HTML + * response. + * + * @param string The URL + * @return string The path name */ - private function createFilenameFromURL(string $url): string + private function createFilenameFromURL($url) { $filepath = str_replace(['https://', 'http://'], ['https/', 'http/'], $url); - if (str_ends_with($filepath, '/')) { + if (substr($filepath, -1) == '/') { $filepath .= 'index.html'; } diff --git a/app/Jobs/ProcessBookmark.php b/app/Jobs/ProcessBookmark.php index 96f65e87..14e4ac4c 100644 --- a/app/Jobs/ProcessBookmark.php +++ b/app/Jobs/ProcessBookmark.php @@ -1,42 +1,45 @@ bookmark = $bookmark; + } /** * Execute the job. + * + * @return void */ - public function handle(): void + public function handle() { - SaveScreenshot::dispatch($this->bookmark); + $uuid = (resolve(BookmarkService::class))->saveScreenshot($this->bookmark->url); + $this->bookmark->screenshot = $uuid; try { $archiveLink = (resolve(BookmarkService::class))->getArchiveLink($this->bookmark->url); - } catch (InternetArchiveException) { + } catch (InternetArchiveException $e) { $archiveLink = null; } $this->bookmark->archive = $archiveLink; diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php index 3c6028a9..22a4eece 100644 --- a/app/Jobs/ProcessLike.php +++ b/app/Jobs/ProcessLike.php @@ -1,105 +1,59 @@ like = $like; + } /** * Execute the job. * - * @throws GuzzleException + * @return void */ - public function handle(Client $client, Authorship $authorship): int + public function handle(Client $client, Authorship $authorship) { - if ($this->isTweet($this->like->url)) { - $codebird = resolve(Codebird::class); - - $tweet = $codebird->statuses_oembed(['url' => $this->like->url]); - - $this->like->author_name = $tweet->author_name; - $this->like->author_url = $tweet->author_url; - $this->like->content = $tweet->html; - $this->like->save(); - - // POSSE like - try { - $client->request( - 'POST', - 'https://brid.gy/publish/webmention', - [ - 'form_params' => [ - 'source' => $this->like->url, - 'target' => 'https://brid.gy/publish/twitter', - ], - ] - ); - } catch (RequestException) { - return 0; - } - - return 0; - } - $response = $client->request('GET', $this->like->url); $mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url); - if (Arr::has($mf2, 'items.0.properties.content')) { + if (array_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 = Arr::get($author, 'properties.name.0'); - $this->like->author_url = Arr::get($author, 'properties.url.0'); + $this->like->author_name = array_get($author, 'properties.name.0'); + $this->like->author_url = array_get($author, 'properties.url.0'); } if (is_string($author) && $author !== '') { $this->like->author_name = $author; } } catch (AuthorshipParserException $exception) { - return 1; + return; } $this->like->save(); - - return 0; - } - - /** - * Determine if a given URL is that of a Tweet. - */ - private function isTweet(string $url): bool - { - $host = parse_url($url, PHP_URL_HOST); - $parts = array_reverse(explode('.', $host)); - - return $parts[0] === 'com' && $parts[1] === 'twitter'; } } diff --git a/app/Jobs/ProcessMedia.php b/app/Jobs/ProcessMedia.php index b7f36648..08985383 100644 --- a/app/Jobs/ProcessMedia.php +++ b/app/Jobs/ProcessMedia.php @@ -1,69 +1,72 @@ filename = $filename; + } /** * Execute the job. + * + * @return void */ - public function handle(ImageManager $manager): void + public function handle(ImageManager $manager) { - // Load file - $file = Storage::disk('local')->get('media/' . $this->filename); - - // Open file + Storage::disk('s3')->put( + 'media/' . $this->filename, + storage_path('app') . '/' . $this->filename + ); + //open file try { - $image = $manager->read($file); - } catch (DecoderException) { + $image = $manager->make(storage_path('app') . '/' . $this->filename); + } catch (NotReadableException $exception) { // not an image; delete file and end job - Storage::disk('local')->delete('media/' . $this->filename); + unlink(storage_path('app') . '/' . $this->filename); return; } - - // Save the file publicly - Storage::disk('public')->put('media/' . $this->filename, $file); - - // Create smaller versions if necessary + //create smaller versions if necessary if ($image->width() > 1000) { $filenameParts = explode('.', $this->filename); $extension = array_pop($filenameParts); - // the following achieves this data flow + // the following acheives this data flow // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar - $basename = trim(implode('.', $filenameParts), '.'); - - $medium = $image->resize(width: 1000); - Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode()); - - $small = $image->resize(width: 500); - Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); + $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()); } - // Now we can delete the locally saved image - Storage::disk('local')->delete('media/' . $this->filename); + // now we can delete the locally saved image + unlink(storage_path('app') . '/' . $this->filename); } } diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index d92dfa18..61e13036 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -1,45 +1,45 @@ note = $note; + $this->source = $source; + } /** * Execute the job. * - * @throws RemoteContentNotFoundException - * @throws GuzzleException - * @throws InvalidMentionException + * @param \Jonnybarnes\WebmentionsParser\Parser $parser + * @param \GuzzleHttp\Client $guzzle + * @return void */ - public function handle(Parser $parser, Client $guzzle): void + public function handle(Parser $parser, Client $guzzle) { 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->uri) === false) { + if ($webmention->type == 'in-reply-to') { + if ($parser->checkInReplyTo($microformats, $this->note->longurl) == false) { // it doesn’t so delete $webmention->delete(); return; } - // webmention is still a reply, so update content + // webmenion 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, $this->note->uri) === false) { + if ($webmention->type == 'like-of') { + if ($parser->checkLikeOf($microformats, $note->longurl) == 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, $this->note->uri) === false) { + if ($webmention->type == 'repost-of') { + if ($parser->checkRepostOf($microformats, $note->longurl) == 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->uri; + $webmention->target = $this->note->longurl; $webmention->commentable_id = $this->note->id; - $webmention->commentable_type = Note::class; + $webmention->commentable_type = 'App\Note'; $webmention->type = $type; $webmention->mf2 = json_encode($microformats); $webmention->save(); @@ -99,23 +99,27 @@ class ProcessWebMention implements ShouldQueue /** * Save the HTML of a webmention for future use. + * + * @param string $html + * @param string $url + * @return string|null */ - private function saveRemoteContent(string $html, string $url): void + private function saveRemoteContent($html, $url) { $filenameFromURL = str_replace( ['https://', 'http://'], ['https/', 'http/'], $url ); - if (str_ends_with($url, '/')) { + if (substr($url, -1) == '/') { $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) && ! is_dir($dir)) { - throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir)); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); } file_put_contents("$dir/$name", $html); } diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php index 08152d5b..2c657c14 100644 --- a/app/Jobs/SaveProfileImage.php +++ b/app/Jobs/SaveProfileImage.php @@ -1,79 +1,66 @@ microformats = $microformats; + } /** * Execute the job. + * + * @return void */ - public function handle(Authorship $authorship): void + public function handle(Authorship $authorship) { try { $author = $authorship->findAuthor($this->microformats); - } catch (AuthorshipParserException) { + } catch (AuthorshipParserException $e) { return; } - - $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' - ) { + $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') { $client = resolve(Client::class); - try { $response = $client->get($photo); - $image = $response->getBody(); - } catch (RequestException) { - // we are opening and reading the default image so that + $image = $response->getBody(true); + } catch (RequestException $e) { + // we are openning 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) && ! is_dir($dir)) { - throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir)); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); } file_put_contents("$dir/$name", $image); } diff --git a/app/Jobs/SaveScreenshot.php b/app/Jobs/SaveScreenshot.php deleted file mode 100755 index 0e07efbd..00000000 --- a/app/Jobs/SaveScreenshot.php +++ /dev/null @@ -1,103 +0,0 @@ -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 2ff5f2c6..0635e117 100644 --- a/app/Jobs/SendWebMentions.php +++ b/app/Jobs/SendWebMentions.php @@ -1,51 +1,49 @@ note = $note; + } /** * Execute the job. * - * @throws GuzzleException + * @return void */ - public function handle(): void + public function handle() { - $urlsInReplyTo = explode(' ', $this->note->in_reply_to ?? ''); + //grab the URLs + $urlsInReplyTo = explode(' ', $this->note->in_reply_to); $urlsNote = $this->getLinks($this->note->note); - $urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); + $urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); //filter out none URLs foreach ($urls as $url) { $endpoint = $this->discoverWebmentionEndpoint($url); if ($endpoint !== null) { $guzzle = resolve(Client::class); $guzzle->post($endpoint, [ 'form_params' => [ - 'source' => $this->note->uri, + 'source' => $this->note->longurl, 'target' => $url, ], ]); @@ -56,31 +54,32 @@ class SendWebMentions implements ShouldQueue /** * Discover if a URL has a webmention endpoint. * - * @throws GuzzleException + * @param string The URL + * @return string The webmention endpoint URL */ - public function discoverWebmentionEndpoint(string $url): ?string + public function discoverWebmentionEndpoint($url) { - // let’s not send webmentions to myself - if (parse_url($url, PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST)) { - return null; + //let’s not send webmentions to myself + if (parse_url($url, PHP_URL_HOST) == config('app.longurl')) { + return; } - if (Str::startsWith($url, '/notes/tagged/')) { - return null; + if (starts_with($url, '/notes/tagged/')) { + return; } $endpoint = null; $guzzle = resolve(Client::class); $response = $guzzle->get($url); - // check HTTP Headers for webmention endpoint - $links = Header::parse($response->getHeader('Link')); + //check HTTP Headers for webmention endpoint + $links = \GuzzleHttp\Psr7\parse_header($response->getHeader('Link')); foreach ($links as $link) { - if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { + if (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); @@ -90,25 +89,24 @@ class SendWebMentions implements ShouldQueue } elseif (array_key_exists('http://webmention.org/', $rels[0])) { $endpoint = $rels[0]['http://webmention.org/'][0]; } - - if ($endpoint === null) { - return null; + if ($endpoint) { + return $this->resolveUri($endpoint, $url); } - - return $this->resolveUri($endpoint, $url); } /** * Get the URLs from a note. + * + * @param string $html + * @return array $urls */ - public function getLinks(?string $html): array + public function getLinks($html) { - 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) { @@ -120,16 +118,20 @@ class SendWebMentions implements ShouldQueue /** * Resolve a URI if necessary. + * + * @param string $url + * @param string $base + * @return string */ public function resolveUri(string $url, string $base): string { - $endpoint = Utils::uriFor($url); - if ($endpoint->getScheme() !== '') { + $endpoint = \GuzzleHttp\Psr7\uri_for($url); + if ($endpoint->getScheme() != '') { return (string) $endpoint; } - return (string) UriResolver::resolve( - Utils::uriFor($base), + return (string) \GuzzleHttp\Psr7\Uri::resolve( + \GuzzleHttp\Psr7\uri_for($base), $endpoint ); } diff --git a/app/Jobs/SyndicateBookmarkToFacebook.php b/app/Jobs/SyndicateBookmarkToFacebook.php new file mode 100644 index 00000000..dc555f8a --- /dev/null +++ b/app/Jobs/SyndicateBookmarkToFacebook.php @@ -0,0 +1,57 @@ +bookmark = $bookmark; + } + + /** + * Execute the job. + * + * @return void + */ + 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 new file mode 100644 index 00000000..9bcebda2 --- /dev/null +++ b/app/Jobs/SyndicateBookmarkToTwitter.php @@ -0,0 +1,57 @@ +bookmark = $bookmark; + } + + /** + * Execute the job. + * + * @return void + */ + 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 deleted file mode 100644 index e815be34..00000000 --- a/app/Jobs/SyndicateNoteToBluesky.php +++ /dev/null @@ -1,62 +0,0 @@ -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 new file mode 100644 index 00000000..82f1b555 --- /dev/null +++ b/app/Jobs/SyndicateNoteToFacebook.php @@ -0,0 +1,54 @@ +note = $note; + } + + /** + * Execute the job. + * + * @return void + */ + 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 deleted file mode 100644 index b79c092c..00000000 --- a/app/Jobs/SyndicateNoteToMastodon.php +++ /dev/null @@ -1,63 +0,0 @@ -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 new file mode 100644 index 00000000..611dd590 --- /dev/null +++ b/app/Jobs/SyndicateNoteToTwitter.php @@ -0,0 +1,56 @@ +note = $note; + } + + /** + * Execute the job. + * + * @param \GuzzleHttp\Client $guzzle + * @return void + */ + 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 bfbd5d51..e68994ae 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -1,51 +1,37 @@ */ - 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 + public function sluggable() { return [ 'titleurl' => [ @@ -54,62 +40,89 @@ class Article extends Model ]; } - protected function html(): Attribute - { - 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); + /** + * We shall set a blacklist of non-modifiable model attributes. + * + * @var array + */ + protected $guarded = ['id']; - return $markdownConverter->convert($this->main)->getContent(); - }, - ); + /** + * Process the article for display. + * + * @return string + */ + public function getHtmlAttribute() + { + $markdown = new CommonMarkConverter(); + $html = $markdown->convertToHtml($this->main); + // changes
[lang] ~> 

+        $match = '/
\[(.*)\]\n/';
+        $replace = '
';
+        $text = preg_replace($match, $replace, $html);
+        $default = preg_replace('/
/', '
', $text);
+
+        return $default;
     }
 
-    protected function w3cTime(): Attribute
+    /**
+     * Convert updated_at to W3C time format.
+     *
+     * @return string
+     */
+    public function getW3cTimeAttribute()
     {
-        return Attribute::get(
-            get: fn () => $this->updated_at->toW3CString(),
-        );
+        return $this->updated_at->toW3CString();
     }
 
-    protected function tooltipTime(): Attribute
+    /**
+     * Convert updated_at to a tooltip appropriate format.
+     *
+     * @return string
+     */
+    public function getTooltipTimeAttribute()
     {
-        return Attribute::get(
-            get: fn () => $this->updated_at->toRFC850String(),
-        );
+        return $this->updated_at->toRFC850String();
     }
 
-    protected function humanTime(): Attribute
+    /**
+     * Convert updated_at to a human readable format.
+     *
+     * @return string
+     */
+    public function getHumanTimeAttribute()
     {
-        return Attribute::get(
-            get: fn () => $this->updated_at->diffForHumans(),
-        );
+        return $this->updated_at->diffForHumans();
     }
 
-    protected function pubdate(): Attribute
+    /**
+     * Get the pubdate value for RSS feeds.
+     *
+     * @return string
+     */
+    public function getPubdateAttribute()
     {
-        return Attribute::get(
-            get: fn () => $this->updated_at->toRSSString(),
-        );
+        return $this->updated_at->toRSSString();
     }
 
-    protected function link(): Attribute
+    /**
+     * A link to the article, i.e. `/blog/1999/12/25/merry-christmas`.
+     *
+     * @return string
+     */
+    public function getLinkAttribute()
     {
-        return Attribute::get(
-            get: fn () => '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl,
-        );
+        return '/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(Builder $query, ?int $year = null, ?int $month = null): Builder
+    public function scopeDate($query, int $year = null, int $month = null)
     {
-        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
deleted file mode 100644
index b9a0e78b..00000000
--- a/app/Models/Bio.php
+++ /dev/null
@@ -1,11 +0,0 @@
- */
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
     protected $fillable = ['url', 'name', 'content'];
 
-    /** @var array */
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array
+     */
     protected $casts = [
         'syndicates' => 'array',
     ];
 
-    public function tags(): BelongsToMany
+    /**
+     * The tags that belong to the bookmark.
+     */
+    public function tags()
     {
         return $this->belongsToMany('App\Models\Tag');
     }
 
-    protected function local_uri(): Attribute
+    /**
+     * The full url of a bookmark.
+     */
+    public function getLongurlAttribute()
     {
-        return Attribute::get(
-            get: fn () => config('app.url') . '/bookmarks/' . $this->id,
-        );
+        return config('app.url') . '/bookmarks/' . $this->id;
     }
 }
diff --git a/app/Models/Contact.php b/app/Models/Contact.php
index 6f193f41..b77decc7 100644
--- a/app/Models/Contact.php
+++ b/app/Models/Contact.php
@@ -1,36 +1,22 @@
  */
+    /**
+     * We shall guard against mass-migration.
+     *
+     * @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 f9ac3bcb..54afba03 100644
--- a/app/Models/Like.php
+++ b/app/Models/Like.php
@@ -1,56 +1,44 @@
  */
     protected $fillable = ['url'];
 
-    protected function url(): Attribute
+    public function setUrlAttribute($value)
     {
-        return Attribute::set(
-            set: fn ($value) => normalize_url($value),
-        );
+        $this->attributes['url'] = normalize_url($value);
     }
 
-    protected function authorUrl(): Attribute
+    public function setAuthorUrlAttribute($value)
     {
-        return Attribute::set(
-            set: fn ($value) => normalize_url($value),
-        );
+        $this->attributes['author_url'] = normalize_url($value);
     }
 
-    protected function content(): Attribute
+    public function getContentAttribute($value)
     {
-        return Attribute::get(
-            get: function ($value, $attributes) {
-                if ($value === null) {
-                    return null;
-                }
+        if ($value === null) {
+            return null;
+        }
 
-                $mf2 = Mf2\parse($value, $attributes['url']);
+        $mf2 = Mf2\parse($value, $this->url);
 
-                if (Arr::get($mf2, 'items.0.properties.content.0.html')) {
-                    return $this->filterHtml(
-                        $mf2['items'][0]['properties']['content'][0]['html']
-                    );
-                }
+        return $this->filterHTML($mf2['items'][0]['properties']['content'][0]['html']);
+    }
 
-                return $value;
-            }
-        );
+    public function filterHTML($html)
+    {
+        $config = HTMLPurifier_Config::createDefault();
+        $config->set('Cache.SerializerPath', storage_path() . '/HTMLPurifier');
+        $config->set('HTML.TargetBlank', true);
+        $purifier = new HTMLPurifier($config);
+
+        return $purifier->purify($html);
     }
 }
diff --git a/app/Models/Media.php b/app/Models/Media.php
index 3d923bed..232d3132 100644
--- a/app/Models/Media.php
+++ b/app/Models/Media.php
@@ -1,99 +1,87 @@
  */
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
     protected $fillable = ['token', 'path', 'type', 'image_widths'];
 
-    public function note(): BelongsTo
+    /**
+     * Get the note that owns this media.
+     */
+    public function note()
     {
-        return $this->belongsTo(Note::class);
+        return $this->belongsTo('App\Models\Note');
     }
 
-    protected function url(): Attribute
+    /**
+     * Get the URL for an S3 media file.
+     *
+     * @return string
+     */
+    public function getUrlAttribute()
     {
-        return Attribute::get(
-            get: function ($value, $attributes) {
-                if (Str::startsWith($attributes['path'], 'https://')) {
-                    return $attributes['path'];
-                }
+        if (starts_with($this->path, 'https://')) {
+            return $this->path;
+        }
 
-                return config('app.url') . '/storage/' . $attributes['path'];
-            }
-        );
+        return config('filesystems.disks.s3.url') . '/' . $this->path;
     }
 
-    protected function mediumurl(): Attribute
+    /**
+     * Get the URL for the medium size of an S3 image file.
+     *
+     * @return string
+     */
+    public function getMediumurlAttribute()
     {
-        return Attribute::get(
-            get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'medium'),
-        );
+        $basename = $this->getBasename($this->path);
+        $extension = $this->getExtension($this->path);
+
+        return config('filesystems.disks.s3.url') . '/' . $basename . '-medium.' . $extension;
     }
 
-    protected function smallurl(): Attribute
+    /**
+     * Get the URL for the small size of an S3 image file.
+     *
+     * @return string
+     */
+    public function getSmallurlAttribute()
     {
-        return Attribute::get(
-            get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'small'),
-        );
+        $basename = $this->getBasename($this->path);
+        $extension = $this->getExtension($this->path);
+
+        return config('filesystems.disks.s3.url') . '/' . $basename . '-small.' . $extension;
     }
 
-    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
+    public function getBasename($path)
     {
         // the following achieves this data flow
         // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
         $filenameParts = explode('.', $path);
         array_pop($filenameParts);
-
-        return ltrim(array_reduce($filenameParts, static function ($carry, $item) {
+        $basename = ltrim(array_reduce($filenameParts, function ($carry, $item) {
             return $carry . '.' . $item;
         }, ''), '.');
+
+        return $basename;
     }
 
-    private function getExtension(string $path): string
+    public function getExtension($path)
     {
         $parts = explode('.', $path);
 
diff --git a/app/Models/MicropubClient.php b/app/Models/MicropubClient.php
index 669c7284..d769b193 100644
--- a/app/Models/MicropubClient.php
+++ b/app/Models/MicropubClient.php
@@ -1,24 +1,31 @@
  */
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
     protected $fillable = ['client_url', 'client_name'];
 
-    public function notes(): HasMany
+    /**
+     * Define the relationship with notes.
+     *
+     * @return void
+     */
+    public function notes()
     {
         return $this->hasMany('App\Models\Note', 'client_id', 'client_url');
     }
diff --git a/app/Models/Note.php b/app/Models/Note.php
index 74533443..256ed639 100644
--- a/app/Models/Note.php
+++ b/app/Models/Note.php
@@ -1,233 +1,263 @@
 contacts = null;
     }
 
-    /** @var string */
+    /**
+     * The database table used by the model.
+     *
+     * @var string
+     */
     protected $table = 'notes';
 
-    /** @var array */
+    /*
+     * Mass-assignment
+     *
+     * @var array
+     */
     protected $fillable = [
         'note',
         'in_reply_to',
         'client_id',
     ];
 
-    /** @var array */
+    /**
+     * Hide the column used with Laravel Scout.
+     *
+     * @var array
+     */
     protected $hidden = ['searchable'];
 
-    public function tags(): BelongsToMany
+    /**
+     * Define the relationship with tags.
+     *
+     * @var array
+     */
+    public function tags()
     {
-        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);
+        return $this->belongsToMany('App\Models\Tag');
     }
 
     /**
-     * @return array
+     * Define the relationship with clients.
+     *
+     * @var array?
      */
-    public function toSearchableArray(): array
+    public function client()
+    {
+        return $this->belongsTo('App\Models\MicropubClient', 'client_id', 'client_url');
+    }
+
+    /**
+     * Define the relationship with webmentions.
+     *
+     * @var array
+     */
+    public function webmentions()
+    {
+        return $this->morphMany('App\Models\WebMention', 'commentable');
+    }
+
+    /**
+     * Definte the relationship with places.
+     *
+     * @var array
+     */
+    public function place()
+    {
+        return $this->belongsTo('App\Models\Place');
+    }
+
+    /**
+     * Define the relationship with media.
+     *
+     * @return void
+     */
+    public function media()
+    {
+        return $this->hasMany('App\Models\Media');
+    }
+
+    /**
+     * Set the attributes to be indexed for searching with Scout.
+     *
+     * @return array
+     */
+    public function toSearchableArray()
     {
         return [
             'note' => $this->note,
         ];
     }
 
-    public function setNoteAttribute(?string $value): void
+    /**
+     * Normalize the note to Unicode FORM C.
+     *
+     * @param  string  $value
+     * @return string
+     */
+    public function setNoteAttribute($value)
     {
-        if ($value !== null) {
-            $normalized = normalizer_normalize($value, Normalizer::FORM_C);
-            if ($normalized === '') { // we don’t want to save empty strings to the db
-                $normalized = null;
-            }
-            $this->attributes['note'] = $normalized;
-        }
+        $this->attributes['note'] = normalizer_normalize($value, Normalizer::FORM_C);
     }
 
     /**
      * Pre-process notes for web-view.
+     *
+     * @param  string
+     * @return string
      */
-    public function getNoteAttribute(?string $value): ?string
+    public function getNoteAttribute($value)
     {
-        if ($value === null && $this->place !== null) {
-            $value = '📍: ' . $this->place->name . '';
-        }
+        $emoji = new EmojiModifier();
 
-        // if $value is still null, just return null
-        if ($value === null) {
-            return null;
-        }
+        $hcards = $this->makeHCards($value);
+        $hashtags = $this->autoLinkHashtag($hcards);
+        $html = $this->convertMarkdown($hashtags);
+        $modified = $emoji->makeEmojiAccessible($html);
 
-        $hashtags = $this->autoLinkHashtag($value);
-
-        return $this->convertMarkdown($hashtags);
+        return $modified;
     }
 
     /**
-     * Provide the content_html for JSON feed.
+     * Generate the NewBase60 ID from primary ID.
      *
-     * In particular, we want to include media links such as images.
+     * @return string
      */
-    public function getContentAttribute(): string
+    public function getNb60idAttribute()
     {
-        $note = $this->getRawOriginal('note');
+        $numbers = new Numbers();
 
-        foreach ($this->media as $media) {
-            if ($media->type === 'image') {
-                $note .= PHP_EOL . '';
-            }
-            if ($media->type === 'audio') {
-                $note .= PHP_EOL . '