diff --git a/.editorconfig b/.editorconfig index 0dede531..0b5d680f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,5 @@ -# 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 @@ -12,10 +8,8 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -# Tab indentation -[Makefile] -indent_style = tab -tab_width = 4 +[*.{js,css}] +indent_size = 2 [*.md] trim_trailing_whitespace = false diff --git a/.env.dusk.testing b/.env.dusk.testing deleted file mode 100644 index 756f4074..00000000 --- a/.env.dusk.testing +++ /dev/null @@ -1,14 +0,0 @@ -APP_ENV=testing -APP_DEBUG=true -APP_KEY=base64:6DJhvZLVjE6dD4Cqrteh+6Z5vZlG+v/soCKcDHLOAH0= -APP_URL=http://localhost:8000 -APP_LONGURL=localhost -APP_SHORTURL=local - -DB_CONNECTION=travis - -CACHE_DRIVER=array -SESSION_DRIVER=file -QUEUE_DRIVER=sync - -SCOUT_DRIVER=pgsql diff --git a/.env.example b/.env.example index da14b175..4eb61db5 100644 --- a/.env.example +++ b/.env.example @@ -2,11 +2,22 @@ APP_NAME=Laravel APP_ENV=local APP_KEY= APP_DEBUG=true +APP_TIMEZONE=UTC APP_URL=https://example.com -APP_LONGURL=example.com -APP_SHORTURL=examp.le + +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 @@ -17,22 +28,30 @@ DB_DATABASE=laravel DB_USERNAME=root DB_PASSWORD= -BROADCAST_DRIVER=log -CACHE_DRIVER=file -FILESYSTEM_DISK=local -QUEUE_CONNECTION=sync -SESSION_DRIVER=file +SESSION_DRIVER=database SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 +REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=smtp -MAIL_HOST=mailhog -MAIL_PORT=1025 +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null @@ -45,19 +64,7 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false -PUSHER_APP_ID= -PUSHER_APP_KEY= -PUSHER_APP_SECRET= -PUSHER_HOST= -PUSHER_PORT=443 -PUSHER_SCHEME=https -PUSHER_APP_CLUSTER=mt1 - -VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" -VITE_PUSHER_HOST="${PUSHER_HOST}" -VITE_PUSHER_PORT="${PUSHER_PORT}" -VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" -VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" +VITE_APP_NAME="${APP_NAME}" ADMIN_USER=admin# pick something better, this is used for `/admin` ADMIN_PASS=password @@ -71,19 +78,13 @@ TWITTER_ACCESS_TOKEN_SECRET= SCOUT_DRIVER=database SCOUT_QUEUE=false -PIWIK=false -PIWIK_ID=1 -PIWIK_URL=https://analytics.jmb.lv/piwik.php - -FATHOM_ID= - -APP_TIMEZONE=UTC -APP_LANG=en -APP_LOG=daily SESSION_SECURE_COOKIE=true +SESSION_SAME_SITE=strict LOG_SLACK_WEBHOOK_URL= -FONT_LINK= +FLARE_KEY= + +IGNITION_OPEN_AI_KEY= BRIDGY_MASTODON_TOKEN= diff --git a/.env.github b/.env.github deleted file mode 100644 index 63cfb2db..00000000 --- a/.env.github +++ /dev/null @@ -1,67 +0,0 @@ -APP_NAME=Laravel -APP_ENV=testing -APP_KEY=SomeRandomString # Leave this -APP_DEBUG=false -APP_LOG_LEVEL=warning - -DB_CONNECTION=pgsql -DB_HOST=127.0.0.1 -DB_PORT=5432 -DB_DATABASE=jbukdev_testing -DB_USERNAME=postgres -DB_PASSWORD=postgres - -BROADCAST_DRIVER=log -CACHE_DRIVER=file -SESSION_DRIVER=file -QUEUE_DRIVER=sync - -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_DRIVER=smtp -MAIL_HOST=smtp.mailtrap.io -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null - -PUSHER_APP_ID= -PUSHER_APP_KEY= -PUSHER_APP_SECRET= - -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 - -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 - -TWITTER_CONSUMER_KEY= -TWITTER_CONSUMER_SECRET= -TWITTER_ACCESS_TOKEN= -TWITTER_ACCESS_TOKEN_SECRET= - -SCOUT_DRIVER=database -SCOUT_QUEUE=false - -PIWIK=false - -FATHOM_ID= - -APP_TIMEZONE=UTC -APP_LANG=en -APP_LOG=daily -SECURE_SESSION_COOKIE=true - -LOG_SLACK_WEBHOOK_URL= - -FONT_LINK= diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index b6ca2fd4..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,24 +0,0 @@ -parserOptions: - sourceType: 'module' -extends: 'eslint:recommended' -env: - browser: true - es6: true -rules: - indent: - - error - - 4 - linebreak-style: - - error - - unix - quotes: - - error - - single - semi: - - error - - always - no-console: - - error - - allow: - - warn - - error diff --git a/.gitattributes b/.gitattributes index 967315dd..78f41d7a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ -* text=auto -*.css linguist-vendored -*.scss linguist-vendored -*.js linguist-vendored -CHANGELOG.md export-ignore +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 47abe68c..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: "composer" - directory: "/" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 4b65cf07..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,142 +0,0 @@ -name: Deploy - -on: - workflow_dispatch: - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - environment: Hetzner - env: - repository: 'jonnybarnes/jonnybarnes.uk' - newReleaseName: '${{ github.run_id }}' - - steps: - - name: 🌍 Set Environment Variables - run: | - echo "releasesDir=${{ secrets.DEPLOYMENT_BASE_DIR }}/releases" >> $GITHUB_ENV - echo "persistentDir=${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent" >> $GITHUB_ENV - echo "currentDir=${{ secrets.DEPLOYMENT_BASE_DIR }}/current" >> $GITHUB_ENV - - name: 🌎 Set Environment Variables Part 2 - run: | - echo "newReleaseDir=${{ env.releasesDir }}/${{ env.newReleaseName }}" >> $GITHUB_ENV - - name: 🔄 Clone Repository - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEPLOYMENT_HOST }} - port: ${{ secrets.DEPLOYMENT_PORT }} - username: ${{ secrets.DEPLOYMENT_USER }} - key: ${{ secrets.DEPLOYMENT_KEY }} - script: | - [ -d ${{ env.releasesDir }} ] || mkdir ${{ env.releasesDir }} - [ -d ${{ env.persistentDir }} ] || mkdir ${{ env.persistentDir }} - [ -d ${{ env.persistentDir }}/storage ] || mkdir ${{ env.persistentDir }}/storage - - cd ${{ env.releasesDir }} - - # Create new release directory - mkdir ${{ env.newReleaseDir }} - - # Clone app - git clone --depth 1 --branch ${{ github.ref_name }} https://github.com/${{ env.repository }} ${{ env.newReleaseName }} - - # Mark release - cd ${{ env.newReleaseDir }} - echo "${{ env.newReleaseName }}" > public/release-name.txt - - # Fix cache directory permissions - sudo chown -R ${{ secrets.HTTP_USER }}:${{ secrets.HTTP_USER }} bootstrap/cache - - - name: 🎵 Run Composer - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEPLOYMENT_HOST }} - port: ${{ secrets.DEPLOYMENT_PORT }} - username: ${{ secrets.DEPLOYMENT_USER }} - key: ${{ secrets.DEPLOYMENT_KEY }} - script: | - cd ${{ env.newReleaseDir }} - composer install --prefer-dist --no-scripts --no-dev --no-progress --optimize-autoloader --quiet --no-interaction - - - name: 🔗 Update Symlinks - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEPLOYMENT_HOST }} - port: ${{ secrets.DEPLOYMENT_PORT }} - username: ${{ secrets.DEPLOYMENT_USER }} - key: ${{ secrets.DEPLOYMENT_KEY }} - script: | - # Import the environment config - cd ${{ env.newReleaseDir }}; - ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/.env .env; - - # Remove the storage directory and replace with persistent data - rm -rf ${{ env.newReleaseDir }}/storage; - cd ${{ env.newReleaseDir }}; - ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/storage storage; - - # Remove the public/profile-images directory and replace with persistent data - rm -rf ${{ env.newReleaseDir }}/public/assets/profile-images; - cd ${{ env.newReleaseDir }}; - ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/profile-images public/assets/profile-images; - - # Add the persistent files data - cd ${{ env.newReleaseDir }}; - ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/files public/files; - - # Add the persistent fonts data - cd ${{ env.newReleaseDir }}; - ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/fonts public/fonts; - - - name: ✨ Optimize Installation - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEPLOYMENT_HOST }} - port: ${{ secrets.DEPLOYMENT_PORT }} - username: ${{ secrets.DEPLOYMENT_USER }} - key: ${{ secrets.DEPLOYMENT_KEY }} - script: | - cd ${{ env.newReleaseDir }}; - sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan clear-compiled; - - - name: 🙈 Migrate database - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEPLOYMENT_HOST }} - port: ${{ secrets.DEPLOYMENT_PORT }} - username: ${{ secrets.DEPLOYMENT_USER }} - key: ${{ secrets.DEPLOYMENT_KEY }} - script: | - cd ${{ env.newReleaseDir }} - sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan migrate --force - - - name: 🙏 Bless release - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEPLOYMENT_HOST }} - port: ${{ secrets.DEPLOYMENT_PORT }} - username: ${{ secrets.DEPLOYMENT_USER }} - key: ${{ secrets.DEPLOYMENT_KEY }} - script: | - ln -nfs ${{ env.newReleaseDir }} ${{ env.currentDir }}; - cd ${{ env.newReleaseDir }} - sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan horizon:terminate - sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan config:cache - sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan event:cache - sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan route:cache - sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan view:cache - - sudo systemctl restart php-fpm.service - sudo systemctl restart jbuk-horizon.service - - - name: 🚾 Clean up old releases - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEPLOYMENT_HOST }} - port: ${{ secrets.DEPLOYMENT_PORT }} - username: ${{ secrets.DEPLOYMENT_USER }} - key: ${{ secrets.DEPLOYMENT_KEY }} - script: | - fd '.+' ${{ env.releasesDir }} -d 1 | head -n -3 | xargs -d "\n" -I'{}' sudo chown -R ${{ secrets.DEPLOYMENT_USER }}:${{ secrets.DEPLOYMENT_USER }} {}/bootstrap/cache; - fd '.+' ${{ env.releasesDir }} -d 1 | head -n -3 | xargs -d "\n" -I'{}' rm -rf {}; diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml deleted file mode 100644 index bee15adf..00000000 --- a/.github/workflows/phpunit.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: PHP Unit - -on: - pull_request: - -jobs: - phpunit: - runs-on: ubuntu-latest - - name: PHPUnit test suite - - services: - postgres: - image: postgres:latest - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: jbukdev_testing - ports: - - 5432:5432 - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Install Node and dependencies - uses: actions/setup-node@v3 - with: - node-version: 18 - cache: 'npm' - - run: npm ci - - - name: Setup PHP with pecl extensions - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - extensions: phpredis,imagick - - - name: Copy .env - run: php -r "file_exists('.env') || copy('.env.github', '.env');" - - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Install Composer Dependencies - run: composer install --quiet --no-ansi --no-interaction --no-progress - - - name: Generate Key - run: php artisan key:generate - - - name: Setup Directory Permissions - run: chmod -R 777 storage bootstrap/cache - - - name: Setup Database - run: php artisan migrate - - - name: Execute PHPUnit Tests - run: vendor/bin/phpunit diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml deleted file mode 100644 index 88893a5f..00000000 --- a/.github/workflows/pint.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Laravel Pint - -on: - pull_request: - -jobs: - pint: - runs-on: ubuntu-latest - - name: Laravel Pint - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup PHP with pecl extensions - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Install Composer Dependencies - run: composer install --quiet --no-ansi --no-interaction --no-progress - - - name: Check Files with Laravel Pint - run: vendor/bin/pint --test diff --git a/.gitignore b/.gitignore index 9cf612ac..a0c2459a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,24 @@ +/.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 Homestead.yaml +auth.json npm-debug.log yarn-error.log +/.fleet /.idea -/lsp -.phpstorm.meta.php -_ide_helper.php +/.vscode ray.php -# Custom paths in /public -/public/coverage -/public/hot -/public/storage -/public/fonts -/public/files -/public/keybase.txt -/public/assets/*.map +/public/gpg.key +/public/assets/img/favicon.png diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index 0fb4a09b..00000000 --- a/.styleci.yml +++ /dev/null @@ -1,8 +0,0 @@ -preset: laravel - -disabled: - - concat_without_spaces - - single_import_per_statement - -finder: - path: app/ diff --git a/.stylelintrc b/.stylelintrc index c4ff038c..a9a9091b 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,7 +1,3 @@ { - "extends": ["stylelint-config-standard"], - "rules": { - "indentation": 4, - "import-notation": "string" - } + "extends": ["stylelint-config-standard"] } diff --git a/app/CommonMark/Generators/ContactMentionGenerator.php b/app/CommonMark/Generators/MentionGenerator.php similarity index 84% rename from app/CommonMark/Generators/ContactMentionGenerator.php rename to app/CommonMark/Generators/MentionGenerator.php index 507f2a0f..2ac1a797 100644 --- a/app/CommonMark/Generators/ContactMentionGenerator.php +++ b/app/CommonMark/Generators/MentionGenerator.php @@ -8,7 +8,7 @@ use League\CommonMark\Extension\Mention\Generator\MentionGeneratorInterface; use League\CommonMark\Extension\Mention\Mention; use League\CommonMark\Node\Inline\AbstractInline; -class ContactMentionGenerator implements MentionGeneratorInterface +class MentionGenerator implements MentionGeneratorInterface { public function generateMention(Mention $mention): ?AbstractInline { diff --git a/app/CommonMark/Renderers/ContactMentionRenderer.php b/app/CommonMark/Renderers/ContactMentionRenderer.php deleted file mode 100644 index f227f121..00000000 --- a/app/CommonMark/Renderers/ContactMentionRenderer.php +++ /dev/null @@ -1,24 +0,0 @@ -getIdentifier())->first(); - - if ($contact === null) { - return '@' . $node->getIdentifier() . ''; - } - - return trim(view('templates.mini-hcard', ['contact' => $contact])->render()); - } -} diff --git a/app/CommonMark/Renderers/MentionRenderer.php b/app/CommonMark/Renderers/MentionRenderer.php new file mode 100644 index 00000000..d970fac8 --- /dev/null +++ b/app/CommonMark/Renderers/MentionRenderer.php @@ -0,0 +1,37 @@ +getIdentifier())->first(); + + // If we have a contact, render a mini-hcard + if ($contact) { + // rendering a blade template to a string, so can’t be an HtmlElement + return trim(view('templates.mini-hcard', ['contact' => $contact])->render()); + } + + // Otherwise, check the link is to the Mastodon profile + $mentionText = $node->getIdentifier(); + $parts = explode('@', $mentionText); + + // This is not [@]handle@instance, so return a Twitter link + if (count($parts) === 1) { + return new HtmlElement('a', ['href' => 'https://twitter.com/' . $parts[0]], '@' . $mentionText); + } + + // Render the Mastodon profile link + return new HtmlElement('a', ['href' => 'https://' . $parts[1] . '/@' . $parts[0]], '@' . $mentionText); + } +} diff --git a/app/Console/Commands/CopyMediaToLocal.php b/app/Console/Commands/CopyMediaToLocal.php new file mode 100644 index 00000000..2e8d2bce --- /dev/null +++ b/app/Console/Commands/CopyMediaToLocal.php @@ -0,0 +1,69 @@ +path; + + $this->info('Processing: ' . $filename); + + // If the file is already saved locally skip to next one + if (Storage::disk('local')->exists('public/' . $filename)) { + $this->info('File already exists locally, skipping'); + + continue; + } + + // Copy the file from S3 to the local filesystem + if (! Storage::disk('s3')->exists($filename)) { + $this->error('File does not exist on S3'); + + continue; + } + $contents = Storage::disk('s3')->get($filename); + Storage::disk('local')->put('public/' . $filename, $contents); + + // Copy -medium and -small versions if they exist + $filenameParts = explode('.', $filename); + $extension = array_pop($filenameParts); + $basename = trim(implode('.', $filenameParts), '.'); + $mediumFilename = $basename . '-medium.' . $extension; + $smallFilename = $basename . '-small.' . $extension; + if (Storage::disk('s3')->exists($mediumFilename)) { + Storage::disk('local')->put('public/' . $mediumFilename, Storage::disk('s3')->get($mediumFilename)); + } + if (Storage::disk('s3')->exists($smallFilename)) { + Storage::disk('local')->put('public/' . $smallFilename, Storage::disk('s3')->get($smallFilename)); + } + } + } +} diff --git a/app/Console/Commands/ParseCachedWebMentions.php b/app/Console/Commands/ParseCachedWebMentions.php index 07a6c662..a6b29176 100644 --- a/app/Console/Commands/ParseCachedWebMentions.php +++ b/app/Console/Commands/ParseCachedWebMentions.php @@ -34,7 +34,7 @@ class ParseCachedWebMentions extends Command { $htmlFiles = $filesystem->allFiles(storage_path() . '/HTML'); foreach ($htmlFiles as $file) { - if ($file->getExtension() !== 'backup') { //we don’t want to parse `.backup` files + 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); diff --git a/app/Console/Commands/UpdateWebmentionsRelationship.php b/app/Console/Commands/UpdateWebmentionsRelationship.php new file mode 100644 index 00000000..f5bc1114 --- /dev/null +++ b/app/Console/Commands/UpdateWebmentionsRelationship.php @@ -0,0 +1,36 @@ +where('commentable_type', '=', 'App\Model\Note') + ->update(['commentable_type' => Note::class]); + + $this->info('All webmentions updated to relate to the correct note model class'); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3ba67df6..432844ad 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -7,20 +7,8 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel; class Kernel extends ConsoleKernel { - /** - * The Artisan commands provided by your application. - * - * @var array - */ - protected $commands = [ - Commands\ParseCachedWebMentions::class, - Commands\ReDownloadWebMentions::class, - ]; - /** * Define the application's command schedule. - * - * @codeCoverageIgnore */ protected function schedule(Schedule $schedule): void { @@ -33,7 +21,7 @@ class Kernel extends ConsoleKernel */ protected function commands(): void { - $this->load(__DIR__ . '/Commands'); + $this->load(__DIR__.'/Commands'); require base_path('routes/console.php'); } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 1595d484..cb48444a 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,90 +2,18 @@ namespace App\Exceptions; -use Exception; -use GuzzleHttp\Client; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; -use Illuminate\Http\Request; -use Illuminate\Http\Response; -use Illuminate\Session\TokenMismatchException; -use Illuminate\Support\Facades\Route; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Throwable; -/** - * @codeCoverageIgnore - */ class Handler extends ExceptionHandler { /** - * A list of the exception types that are not reported. - * - * @var array> + * Register the exception handling callbacks for the application. */ - protected $dontReport = [ - NotFoundHttpException::class, - ModelNotFoundException::class, - ]; - - /** - * Report or log an exception. - * - * This is a great spot to send exceptions to Sentry, Bugsnag, etc. - * - * @throws Exception - * @throws Throwable - */ - public function report(Throwable $e): void + public function register(): void { - parent::report($e); - - if (config('logging.slack') && $this->shouldReport($e)) { - $guzzle = new Client([ - 'headers' => [ - 'Content-Type' => 'application/json', - ], - ]); - - $exceptionName = get_class($e) ?? 'Unknown Exception'; - $title = $exceptionName . ': ' . $e->getMessage(); - - $guzzle->post( - config('logging.slack'), - [ - 'body' => json_encode([ - 'attachments' => [[ - 'fallback' => 'There was an exception.', - 'pretext' => 'There was an exception.', - 'color' => '#d00000', - 'author_name' => app()->environment(), - 'author_link' => config('app.url'), - 'fields' => [[ - 'title' => $title, - 'value' => request()->method() . ' ' . request()->fullUrl(), - ]], - 'ts' => time(), - ]], - ]), - ] - ); - } - } - - /** - * Render an exception into an HTTP response. - * - * @param Request $request - * @return Response - * - * @throws Throwable - */ - public function render($request, Throwable $throwable) - { - if ($throwable instanceof TokenMismatchException) { - Route::getRoutes()->match($request); - } - - return parent::render($request, $throwable); + $this->reportable(function (Throwable $_e) { + // + }); } } diff --git a/app/Exceptions/InternetArchiveException.php b/app/Exceptions/InternetArchiveException.php index 7e810fea..99d5cab7 100644 --- a/app/Exceptions/InternetArchiveException.php +++ b/app/Exceptions/InternetArchiveException.php @@ -2,6 +2,4 @@ namespace App\Exceptions; -class InternetArchiveException extends \Exception -{ -} +class InternetArchiveException extends \Exception {} diff --git a/app/Exceptions/InvalidTokenScopeException.php b/app/Exceptions/InvalidTokenScopeException.php new file mode 100644 index 00000000..5966bccd --- /dev/null +++ b/app/Exceptions/InvalidTokenScopeException.php @@ -0,0 +1,7 @@ +hasFile('article')) { $file = request()->file('article')->openFile(); $content = $file->fread($file->getSize()); diff --git a/app/Http/Controllers/Admin/ClientsController.php b/app/Http/Controllers/Admin/ClientsController.php index f7662519..38524b62 100644 --- a/app/Http/Controllers/Admin/ClientsController.php +++ b/app/Http/Controllers/Admin/ClientsController.php @@ -7,7 +7,6 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\MicropubClient; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Illuminate\View\View; class ClientsController extends Controller diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php index bdcdc90d..eb45320c 100644 --- a/app/Http/Controllers/Admin/ContactsController.php +++ b/app/Http/Controllers/Admin/ContactsController.php @@ -9,7 +9,6 @@ use App\Models\Contact; use GuzzleHttp\Client; use Illuminate\Filesystem\Filesystem; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\View\View; @@ -38,7 +37,7 @@ class ContactsController extends Controller */ public function store(): RedirectResponse { - $contact = new Contact(); + $contact = new Contact; $contact->name = request()->input('name'); $contact->nick = request()->input('nick'); $contact->homepage = request()->input('homepage'); @@ -77,7 +76,7 @@ class ContactsController extends Controller if (request()->hasFile('avatar') && (request()->input('homepage') != '')) { $dir = parse_url(request()->input('homepage'), PHP_URL_HOST); $destination = public_path() . '/assets/profile-images/' . $dir; - $filesystem = new Filesystem(); + $filesystem = new Filesystem; if ($filesystem->isDirectory($destination) === false) { $filesystem->makeDirectory($destination); } @@ -137,7 +136,7 @@ class ContactsController extends Controller } if ($avatar !== null) { $directory = public_path() . '/assets/profile-images/' . parse_url($contact->homepage, PHP_URL_HOST); - $filesystem = new Filesystem(); + $filesystem = new Filesystem; if ($filesystem->isDirectory($directory) === false) { $filesystem->makeDirectory($directory); } diff --git a/app/Http/Controllers/Admin/NotesController.php b/app/Http/Controllers/Admin/NotesController.php index ae409291..c6ed93ba 100644 --- a/app/Http/Controllers/Admin/NotesController.php +++ b/app/Http/Controllers/Admin/NotesController.php @@ -64,7 +64,7 @@ class NotesController extends Controller */ public function update(int $noteId): RedirectResponse { - //update note data + // update note data $note = Note::findOrFail($noteId); $note->note = request()->input('content'); $note->in_reply_to = request()->input('in-reply-to'); diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php new file mode 100644 index 00000000..9f635f10 --- /dev/null +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -0,0 +1,326 @@ +user(); + $passkeys = $user->passkey; + + return view('admin.passkeys.index', compact('passkeys')); + } + + /** + * @throws RandomException + * @throws \JsonException + */ + public function getCreateOptions(Request $request): JsonResponse + { + /** @var User $user */ + $user = auth()->user(); + + // RP Entity i.e. the application + $rpEntity = PublicKeyCredentialRpEntity::create( + name: config('app.name'), + id: config('app.url'), + ); + + // User Entity + $userEntity = PublicKeyCredentialUserEntity::create( + name: $user->name, + id: (string) $user->id, + displayName: $user->name, + ); + + // Challenge + $challenge = random_bytes(16); + + // List of supported public key parameters + $pubKeyCredParams = collect([ + Algorithms::COSE_ALGORITHM_EDDSA, + Algorithms::COSE_ALGORITHM_ES256, + Algorithms::COSE_ALGORITHM_RS256, + ])->map( + fn ($algorithm) => PublicKeyCredentialParameters::create('public-key', $algorithm) + )->toArray(); + + $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create( + userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED, + residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED, + ); + + $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::create( + rp: $rpEntity, + user: $userEntity, + challenge: $challenge, + pubKeyCredParams: $pubKeyCredParams, + authenticatorSelection: $authenticatorSelectionCriteria, + attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE + ); + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + $webauthnSerializerFactory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $webauthnSerializer = $webauthnSerializerFactory->create(); + $publicKeyCredentialCreationOptions = $webauthnSerializer->serialize( + data: $publicKeyCredentialCreationOptions, + format: 'json' + ); + + $request->session()->put('create_options', $publicKeyCredentialCreationOptions); + + return JsonResponse::fromJsonString($publicKeyCredentialCreationOptions); + } + + /** + * @throws Throwable + * @throws WebauthnException + * @throws \JsonException + */ + public function create(Request $request): JsonResponse + { + /** @var User $user */ + $user = auth()->user(); + + $publicKeyCredentialCreationOptionsData = session('create_options'); + // Unset session data to mitigate replay attacks + $request->session()->forget('create_options'); + if (empty($publicKeyCredentialCreationOptionsData)) { + throw new WebAuthnException('No public key credential request options found'); + } + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + $webauthnSerializerFactory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $webauthnSerializer = $webauthnSerializerFactory->create(); + + $publicKeyCredential = $webauthnSerializer->deserialize( + json_encode($request->all(), JSON_THROW_ON_ERROR), + PublicKeyCredential::class, + 'json' + ); + + if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) { + throw new WebAuthnException('Invalid response type'); + } + + $algorithmManager = new Manager; + $algorithmManager->add(new Ed25519); + $algorithmManager->add(new ES256); + $algorithmManager->add(new RS256); + + $ceremonyStepManagerFactory = new CeremonyStepManagerFactory; + $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); + $ceremonyStepManagerFactory->setAttestationStatementSupportManager( + $attestationStatementSupportManager + ); + $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( + ExtensionOutputCheckerHandler::create() + ); + $allowedOrigins = []; + if (App::environment('local', 'development')) { + $allowedOrigins = [config('app.url')]; + } + $ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins); + + $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( + ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony() + ); + + $publicKeyCredentialCreationOptions = $webauthnSerializer->deserialize( + $publicKeyCredentialCreationOptionsData, + PublicKeyCredentialCreationOptions::class, + 'json' + ); + + $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( + authenticatorAttestationResponse: $publicKeyCredential->response, + publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions, + host: config('app.url') + ); + + $user->passkey()->create([ + 'passkey_id' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->publicKeyCredentialId), + 'passkey' => json_encode($publicKeyCredentialSource, JSON_THROW_ON_ERROR), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Passkey created successfully', + ]); + } + + /** + * @throws RandomException + * @throws \JsonException + */ + public function getRequestOptions(Request $request): JsonResponse + { + $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create( + challenge: random_bytes(16), + userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED + ); + + $attestationStatementSupportManager = AttestationStatementSupportManager::create(); + $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create()); + $factory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $serializer = $factory->create(); + $publicKeyCredentialRequestOptions = $serializer->serialize(data: $publicKeyCredentialRequestOptions, format: 'json'); + + $request->session()->put('request_options', $publicKeyCredentialRequestOptions); + + return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions); + } + + /** + * @throws \JsonException + */ + public function login(Request $request): JsonResponse + { + $requestOptions = session('request_options'); + $request->session()->forget('request_options'); + + if (empty($requestOptions)) { + return response()->json([ + 'success' => false, + 'message' => 'No request options found', + ], 400); + } + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + + $webauthnSerializerFactory = new WebauthnSerializerFactory( + attestationStatementSupportManager: $attestationStatementSupportManager + ); + $webauthnSerializer = $webauthnSerializerFactory->create(); + + $publicKeyCredential = $webauthnSerializer->deserialize( + json_encode($request->all(), JSON_THROW_ON_ERROR), + PublicKeyCredential::class, + 'json' + ); + + if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid response type', + ], 400); + } + + $passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id); + if (! $passkey) { + return response()->json([ + 'success' => false, + 'message' => 'Passkey not found', + ], 404); + } + + $publicKeyCredentialSource = $webauthnSerializer->deserialize( + $passkey->passkey, + PublicKeyCredentialSource::class, + 'json' + ); + + $algorithmManager = new Manager; + $algorithmManager->add(new Ed25519); + $algorithmManager->add(new ES256); + $algorithmManager->add(new RS256); + + $attestationStatementSupportManager = new AttestationStatementSupportManager; + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + + $ceremonyStepManagerFactory = new CeremonyStepManagerFactory; + $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); + $ceremonyStepManagerFactory->setAttestationStatementSupportManager( + $attestationStatementSupportManager + ); + $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( + ExtensionOutputCheckerHandler::create() + ); + $allowedOrigins = []; + if (App::environment('local', 'development')) { + $allowedOrigins = [config('app.url')]; + } + $ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins); + + $authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create( + ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony() + ); + + $publicKeyCredentialRequestOptions = $webauthnSerializer->deserialize( + $requestOptions, + PublicKeyCredentialRequestOptions::class, + 'json' + ); + + try { + $authenticatorAssertionResponseValidator->check( + publicKeyCredentialSource: $publicKeyCredentialSource, + authenticatorAssertionResponse: $publicKeyCredential->response, + publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions, + host: config('app.url'), + userHandle: null, + ); + } catch (Throwable) { + return response()->json([ + 'success' => false, + 'message' => 'Passkey could not be verified', + ], 500); + } + + $user = User::find($passkey->user_id); + Auth::login($user); + + return response()->json([ + 'success' => true, + 'message' => 'Passkey verified successfully', + ]); + } +} diff --git a/app/Http/Controllers/ArticlesController.php b/app/Http/Controllers/ArticlesController.php index 6ea4d1ed..9ab860d7 100644 --- a/app/Http/Controllers/ArticlesController.php +++ b/app/Http/Controllers/ArticlesController.php @@ -15,12 +15,12 @@ class ArticlesController extends Controller /** * Show all articles (with pagination). */ - public function index(int $year = null, int $month = null): View + public function index(?int $year = null, ?int $month = null): View { $articles = Article::where('published', '1') - ->date($year, $month) - ->orderBy('updated_at', 'desc') - ->simplePaginate(5); + ->date($year, $month) + ->orderBy('updated_at', 'desc') + ->simplePaginate(5); return view('articles.index', compact('articles')); } diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index a37ce8fb..bd0022d6 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -31,7 +31,7 @@ class AuthController extends Controller $credentials = $request->only('name', 'password'); if (Auth::attempt($credentials, true)) { - return redirect()->intended('/'); + return redirect()->intended('/admin'); } return redirect()->route('login'); diff --git a/app/Http/Controllers/ContactsController.php b/app/Http/Controllers/ContactsController.php index 3f1bfcff..280cc3ed 100644 --- a/app/Http/Controllers/ContactsController.php +++ b/app/Http/Controllers/ContactsController.php @@ -15,7 +15,7 @@ class ContactsController extends Controller */ public function index(): View { - $filesystem = new Filesystem(); + $filesystem = new Filesystem; $contacts = Contact::all(); foreach ($contacts as $contact) { $contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST); @@ -37,7 +37,7 @@ class ContactsController extends Controller $contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST); $file = public_path() . '/assets/profile-images/' . $contact->homepageHost . '/image'; - $filesystem = new Filesystem(); + $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 ce1176dd..8677cd5c 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,14 +2,7 @@ namespace App\Http\Controllers; -use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Foundation\Bus\DispatchesJobs; -use Illuminate\Foundation\Validation\ValidatesRequests; -use Illuminate\Routing\Controller as BaseController; - -class Controller extends BaseController +abstract class Controller { - use AuthorizesRequests; - use DispatchesJobs; - use ValidatesRequests; + // } diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index 08c9a3ae..eb0847a3 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -20,8 +20,8 @@ class FeedsController extends Controller $buildDate = $articles->first()->updated_at->toRssString(); return response() - ->view('articles.rss', compact('articles', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->view('articles.rss', compact('articles', 'buildDate')) + ->header('Content-Type', 'application/rss+xml; charset=utf-8'); } /** @@ -32,8 +32,8 @@ class FeedsController extends Controller $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'); } /** @@ -45,8 +45,8 @@ class FeedsController extends Controller $buildDate = $notes->first()->updated_at->toRssString(); return response() - ->view('notes.rss', compact('notes', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->view('notes.rss', compact('notes', 'buildDate')) + ->header('Content-Type', 'application/rss+xml; charset=utf-8'); } /** @@ -57,8 +57,8 @@ class FeedsController extends Controller $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 */ @@ -70,10 +70,16 @@ class FeedsController extends Controller { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1', - 'title' => 'The JSON Feed for ' . config('app.display_name') . '’s blog', + 'version' => 'https://jsonfeed.org/version/1.1', + 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s blog', 'home_page_url' => config('app.url') . '/blog', 'feed_url' => config('app.url') . '/blog/feed.json', + 'authors' => [ + [ + 'name' => config('user.display_name'), + 'url' => config('app.url'), + ], + ], 'items' => [], ]; @@ -85,9 +91,6 @@ class FeedsController extends Controller 'content_html' => $article->main, 'date_published' => $article->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(), - 'author' => [ - 'name' => config('app.display_name'), - ], ]; } @@ -99,26 +102,32 @@ class FeedsController extends Controller */ public function notesJson(): array { - $notes = Note::latest()->with('media')->take(20)->get(); + $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1', - 'title' => 'The JSON Feed for ' . config('app.display_name') . '’s notes', + 'version' => 'https://jsonfeed.org/version/1.1', + 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s notes', 'home_page_url' => config('app.url') . '/notes', 'feed_url' => config('app.url') . '/notes/feed.json', + 'authors' => [ + [ + 'name' => config('user.display_name'), + 'url' => config('app.url'), + ], + ], 'items' => [], ]; foreach ($notes as $key => $note) { $data['items'][$key] = [ - 'id' => $note->longurl, - 'url' => $note->longurl, - 'content_html' => $note->content, + 'id' => $note->uri, + 'url' => $note->uri, + 'content_text' => $note->content, 'date_published' => $note->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $note->updated_at->tz('UTC')->toRfc3339String(), - 'author' => [ - 'name' => config('app.display_name'), - ], ]; + if ($note->tags->count() > 0) { + $data['items'][$key]['tags'] = implode(',', $note->tags->pluck('tag')->toArray()); + } } return $data; @@ -151,8 +160,8 @@ class FeedsController extends Controller 'url' => url('/blog'), 'author' => [ 'type' => 'card', - 'name' => config('user.displayname'), - 'url' => config('app.longurl'), + 'name' => config('user.display_name'), + 'url' => config('app.url'), ], 'children' => $items, ], 200, [ @@ -171,8 +180,8 @@ class FeedsController extends Controller $items[] = [ 'type' => 'entry', 'published' => $note->created_at, - 'uid' => $note->longurl, - 'url' => $note->longurl, + 'uid' => $note->uri, + 'url' => $note->uri, 'content' => [ 'text' => $note->getRawOriginal('note'), 'html' => $note->note, @@ -187,8 +196,8 @@ class FeedsController extends Controller 'url' => url('/notes'), 'author' => [ 'type' => 'card', - 'name' => config('user.displayname'), - 'url' => config('app.longurl'), + 'name' => config('user.display_name'), + 'url' => config('app.url'), ], 'children' => $items, ], 200, [ diff --git a/app/Http/Controllers/FrontPageController.php b/app/Http/Controllers/FrontPageController.php index 52ff2adf..19537663 100644 --- a/app/Http/Controllers/FrontPageController.php +++ b/app/Http/Controllers/FrontPageController.php @@ -7,8 +7,6 @@ use App\Models\Bio; use App\Models\Bookmark; use App\Models\Like; use App\Models\Note; -use App\Services\ActivityStreamsService; -use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\View\View; @@ -17,15 +15,19 @@ class FrontPageController extends Controller /** * Show all the recent activity. */ - public function index(Request $request): Response|View + public function index(): Response|View { - if ($request->wantsActivityStream()) { - return (new ActivityStreamsService())->siteOwnerResponse(); - } - - $notes = Note::latest()->with(['media', 'client', 'place'])->get(); + $notes = Note::latest()->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()->get(); + $bookmarks = Bookmark::latest()->with('tags')->get(); $likes = Like::latest()->get(); $items = collect($notes) diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php new file mode 100644 index 00000000..45b488da --- /dev/null +++ b/app/Http/Controllers/IndieAuthController.php @@ -0,0 +1,327 @@ +json([ + 'issuer' => config('app.url'), + 'authorization_endpoint' => route('indieauth.start'), + 'token_endpoint' => route('indieauth.token'), + 'code_challenge_methods_supported' => ['S256'], + // 'introspection_endpoint' => route('indieauth.introspection'), + // 'introspection_endpoint_auth_methods_supported' => ['none'], + ]); + } + + /** + * Process a GET request to the IndieAuth endpoint. + * + * This is the first step in the IndieAuth flow, where the client app sends the user to the IndieAuth endpoint. + */ + public function start(Request $request): View + { + // First check all required params are present + $validator = Validator::make($request->all(), [ + 'response_type' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'state' => 'required', + 'code_challenge' => 'required:string', + 'code_challenge_method' => 'required:string', + ], [ + 'response_type' => 'response_type is required', + 'client_id.required' => 'client_id is required to display which app is asking for authentication', + 'redirect_uri.required' => 'redirect_uri is required so we can progress successful requests', + 'state.required' => 'state is required', + 'code_challenge.required' => 'code_challenge is required', + 'code_challenge_method.required' => 'code_challenge_method is required', + ]); + + if ($validator->fails()) { + return view('indieauth.error')->withErrors($validator); + } + + if ($request->get('response_type') !== 'code') { + return view('indieauth.error')->withErrors(['response_type' => 'only a response_type of "code" is supported']); + } + + if (mb_strtoupper($request->get('code_challenge_method')) !== 'S256') { + return view('indieauth.error')->withErrors(['code_challenge_method' => 'only a code_challenge_method of "S256" is supported']); + } + + if (! $this->isValidRedirectUri($request->get('client_id'), $request->get('redirect_uri'))) { + return view('indieauth.error')->withErrors(['redirect_uri' => 'redirect_uri is not valid for this client_id']); + } + + $scopes = $request->get('scope', ''); + $scopes = explode(' ', $scopes); + + return view('indieauth.start', [ + 'me' => $request->get('me'), + 'client_id' => $request->get('client_id'), + 'redirect_uri' => $request->get('redirect_uri'), + 'state' => $request->get('state'), + 'scopes' => $scopes, + 'code_challenge' => $request->get('code_challenge'), + 'code_challenge_method' => $request->get('code_challenge_method'), + ]); + } + + /** + * Confirm an IndieAuth approval request. + * + * Generates an auth code and redirects the user back to the client app. + * + * @throws RandomException + */ + public function confirm(Request $request): RedirectResponse + { + $authCode = bin2hex(random_bytes(16)); + + $cacheKey = hash('xxh3', $request->get('client_id')); + + $indieAuthRequestData = [ + 'code_challenge' => $request->get('code_challenge'), + 'code_challenge_method' => $request->get('code_challenge_method'), + 'client_id' => $request->get('client_id'), + 'redirect_uri' => $request->get('redirect_uri'), + 'auth_code' => $authCode, + 'scope' => implode(' ', $request->get('scope', '')), + ]; + + Cache::put($cacheKey, $indieAuthRequestData, now()->addMinutes(10)); + + $redirectUri = new Uri($request->get('redirect_uri')); + $redirectUri = Uri::withQueryValues($redirectUri, [ + 'code' => $authCode, + 'state' => $request->get('state'), + 'iss' => config('app.url'), + ]); + + return redirect()->away($redirectUri); + } + + /** + * Process a POST request to the IndieAuth auth endpoint. + * + * This is one possible second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth + * endpoint. As it is to the auth endpoint we return profile information. A similar request can be made to the token + * endpoint to get an access token. + */ + public function processCodeExchange(Request $request): JsonResponse + { + $invalidCodeResponse = $this->validateAuthorizationCode($request); + + if ($invalidCodeResponse instanceof JsonResponse) { + return $invalidCodeResponse; + } + + return response()->json([ + 'me' => config('app.url'), + ]); + } + + /** + * Process a POST request to the IndieAuth token endpoint. + * + * This is another possible second step in the IndieAuth flow, where the client app sends the auth code to the + * IndieAuth token endpoint. As it is to the token endpoint we return an access token. + * + * @throws SodiumException + */ + public function processTokenRequest(Request $request): JsonResponse + { + $indieAuthData = $this->validateAuthorizationCode($request); + + if ($indieAuthData instanceof JsonResponse) { + return $indieAuthData; + } + + if ($indieAuthData['scope'] === '') { + return response()->json(['errors' => [ + 'scope' => [ + 'The scope property must be non-empty for an access token to be issued.', + ], + ]], 400); + } + + $tokenData = [ + 'me' => config('app.url'), + 'client_id' => $request->get('client_id'), + 'scope' => $indieAuthData['scope'], + ]; + $tokenService = resolve(TokenService::class); + $token = $tokenService->getNewToken($tokenData); + + return response()->json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'scope' => $indieAuthData['scope'], + 'me' => config('app.url'), + ]); + } + + protected function isValidRedirectUri(string $clientId, string $redirectUri): bool + { + // If client_id is not a valid URL, then it's not valid + $clientIdParsed = \Mf2\parseUriToComponents($clientId); + if (! isset($clientIdParsed['authority'])) { + return false; + } + + // If redirect_uri is not a valid URL, then it's not valid + $redirectUriParsed = \Mf2\parseUriToComponents($redirectUri); + if (! isset($redirectUriParsed['authority'])) { + return false; + } + + // If client_id and redirect_uri are the same host, then it's valid + if ($clientIdParsed['authority'] === $redirectUriParsed['authority']) { + return true; + } + + // Otherwise we need to check the redirect_uri is in the client_id's redirect_uris + $guzzle = resolve(Client::class); + + try { + $clientInfo = $guzzle->get($clientId); + } catch (Exception) { + return false; + } + + $clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId); + + $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; + + return in_array($redirectUri, $redirectUris, true); + } + + /** + * @throws SodiumException + */ + protected function validateAuthorizationCode(Request $request): JsonResponse|array + { + // First check all the data is present + $validator = Validator::make($request->all(), [ + 'grant_type' => 'required:string', + 'code' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'code_verifier' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 400); + } + + if ($request->get('grant_type') !== 'authorization_code') { + return response()->json(['errors' => [ + 'grant_type' => [ + 'Only a grant type of "authorization_code" is supported.', + ], + ]], 400); + } + + // Check cache for auth code + $cacheKey = hash('xxh3', $request->get('client_id')); + $indieAuthRequestData = Cache::pull($cacheKey); + + if ($indieAuthRequestData === null) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 404); + } + + // Check the IndieAuth code + if (! array_key_exists('auth_code', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + + // Check code verifier + if (! array_key_exists('code_challenge', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + if (! hash_equals( + $indieAuthRequestData['code_challenge'], + sodium_bin2base64( + hash('sha256', $request->get('code_verifier'), true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ) + )) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + + // Check redirect_uri + if (! array_key_exists('redirect_uri', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['redirect_uri'] !== $request->get('redirect_uri')) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + + // Check client_id + if (! array_key_exists('client_id', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['client_id'] !== $request->get('client_id')) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + + return $indieAuthRequestData; + } +} diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 1d1eccc3..758b3255 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -4,107 +4,73 @@ declare(strict_types=1); namespace App\Http\Controllers; -use App\Http\Responses\MicropubResponses; +use App\Exceptions\InvalidTokenScopeException; +use App\Exceptions\MicropubHandlerException; +use App\Http\Requests\MicropubRequest; use App\Models\Place; use App\Models\SyndicationTarget; -use App\Services\Micropub\HCardService; -use App\Services\Micropub\HEntryService; -use App\Services\Micropub\UpdateService; -use App\Services\TokenService; +use App\Services\Micropub\MicropubHandlerRegistry; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Lcobucci\JWT\Encoding\CannotDecodeContent; -use Lcobucci\JWT\Token\InvalidTokenStructure; -use Lcobucci\JWT\Validation\RequiredConstraintsViolated; -use Monolog\Handler\StreamHandler; -use Monolog\Logger; +use Lcobucci\JWT\Token; class MicropubController extends Controller { - protected TokenService $tokenService; + protected MicropubHandlerRegistry $handlerRegistry; - protected HEntryService $hentryService; - - protected HCardService $hcardService; - - protected UpdateService $updateService; - - public function __construct( - TokenService $tokenService, - HEntryService $hentryService, - HCardService $hcardService, - UpdateService $updateService - ) { - $this->tokenService = $tokenService; - $this->hentryService = $hentryService; - $this->hcardService = $hcardService; - $this->updateService = $updateService; + public function __construct(MicropubHandlerRegistry $handlerRegistry) + { + $this->handlerRegistry = $handlerRegistry; } /** - * This function receives an API request, verifies the authenticity - * then passes over the info to the relevant Service class. + * Respond to a POST request to the micropub endpoint. + * + * The request is initially processed by the MicropubRequest form request + * class. The normalizes the data, so we can pass it into the handlers for + * the different micropub requests, h-entry or h-card, for example. */ - public function post(Request $request): JsonResponse + public function post(MicropubRequest $request): JsonResponse { + $type = $request->getType(); + + if (! $type) { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => 'Microformat object type is missing, for example: h-entry or h-card', + ], 400); + } + try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->invalidTokenResponse(); - } - - if ($tokenData->claims()->has('scope') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->tokenHasNoScopeResponse(); - } - - $this->logMicropubRequest($request->all()); - - if (($request->input('h') === 'entry') || ($request->input('type.0') === 'h-entry')) { - if (stripos($tokenData->claims()->get('scope'), 'create') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->insufficientScopeResponse(); - } - $location = $this->hentryService->process($request->all(), $this->getCLientId()); + $handler = $this->handlerRegistry->getHandler($type); + $result = $handler->handle($request->getMicropubData()); + // Return appropriate response based on the handler result return response()->json([ - 'response' => 'created', - 'location' => $location, - ], 201)->header('Location', $location); - } - - if ($request->input('h') === 'card' || $request->input('type.0') === 'h-card') { - if (stripos($tokenData->claims()->get('scope'), 'create') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->insufficientScopeResponse(); - } - $location = $this->hcardService->process($request->all()); - + 'response' => $result['response'], + 'location' => $result['url'] ?? null, + ], 201)->header('Location', $result['url']); + } catch (\InvalidArgumentException $e) { return response()->json([ - 'response' => 'created', - 'location' => $location, - ], 201)->header('Location', $location); + 'error' => 'invalid_request', + 'error_description' => $e->getMessage(), + ], 400); + } catch (MicropubHandlerException) { + return response()->json([ + 'error' => 'Unknown Micropub type', + 'error_description' => 'The request could not be processed by this server', + ], 500); + } catch (InvalidTokenScopeException) { + return response()->json([ + 'error' => 'invalid_scope', + 'error_description' => 'The token does not have the required scope for this request', + ], 403); + } catch (\Exception) { + return response()->json([ + 'error' => 'server_error', + 'error_description' => 'An error occurred processing the request', + ], 500); } - - if ($request->input('action') === 'update') { - if (stripos($tokenData->claims()->get('scope'), 'update') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->insufficientScopeResponse(); - } - - return $this->updateService->process($request->all()); - } - - return response()->json([ - 'response' => 'error', - 'error_description' => 'unsupported_request_type', - ], 500); } /** @@ -117,12 +83,6 @@ class MicropubController extends Controller */ public function get(Request $request): JsonResponse { - try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure) { - return (new MicropubResponses())->invalidTokenResponse(); - } - if ($request->input('q') === 'syndicate-to') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), @@ -154,36 +114,17 @@ class MicropubController extends Controller ]); } - // default response is just to return the token data + // the default response is just to return the token data + /** @var Token $tokenData */ + $tokenData = $request->input('token_data'); + return response()->json([ 'response' => 'token', 'token' => [ - 'me' => $tokenData->claims()->get('me'), - 'scope' => $tokenData->claims()->get('scope'), - 'client_id' => $tokenData->claims()->get('client_id'), + 'me' => $tokenData['me'], + 'scope' => $tokenData['scope'], + 'client_id' => $tokenData['client_id'], ], ]); } - - /** - * Determine the client id from the access token sent with the request. - * - * @throws RequiredConstraintsViolated - */ - private function getClientId(): string - { - return resolve(TokenService::class) - ->validateToken(app('request')->input('access_token')) - ->claims()->get('client_id'); - } - - /** - * Save the details of the micropub request to a log file. - */ - private function logMicropubRequest(array $request): void - { - $logger = new Logger('micropub'); - $logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log'))); - $logger->debug('MicropubLog', $request); - } } diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index acd8ad0d..fc804ea2 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -7,52 +7,29 @@ namespace App\Http\Controllers; use App\Http\Responses\MicropubResponses; use App\Jobs\ProcessMedia; use App\Models\Media; -use App\Services\TokenService; use Exception; use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Http\File; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; -use Intervention\Image\Exception\NotReadableException; use Intervention\Image\ImageManager; -use Lcobucci\JWT\Token\InvalidTokenStructure; -use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use Ramsey\Uuid\Uuid; class MicropubMediaController extends Controller { - protected TokenService $tokenService; - - public function __construct(TokenService $tokenService) - { - $this->tokenService = $tokenService; - } - public function getHandler(Request $request): JsonResponse { - try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure) { - $micropubResponses = new MicropubResponses(); + $tokenData = $request->input('token_data'); - return $micropubResponses->invalidTokenResponse(); + $scopes = $tokenData['scope']; + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); } - - if ($tokenData->claims()->has('scope') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->tokenHasNoScopeResponse(); - } - - if (Str::contains($tokenData->claims()->get('scope'), 'create') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->insufficientScopeResponse(); + if (! in_array('create', $scopes, true)) { + return (new MicropubResponses)->insufficientScopeResponse(); } if ($request->input('q') === 'last') { @@ -103,24 +80,14 @@ class MicropubMediaController extends Controller */ public function media(Request $request): JsonResponse { - try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure $exception) { - $micropubResponses = new MicropubResponses(); + $tokenData = $request->input('token_data'); - return $micropubResponses->invalidTokenResponse(); + $scopes = $tokenData['scope']; + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); } - - if ($tokenData->claims()->has('scope') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->tokenHasNoScopeResponse(); - } - - if (Str::contains($tokenData->claims()->get('scope'), 'create') === false) { - $micropubResponses = new MicropubResponses(); - - return $micropubResponses->insufficientScopeResponse(); + if (! in_array('create', $scopes, true)) { + return (new MicropubResponses)->insufficientScopeResponse(); } if ($request->hasFile('file') === false) { @@ -131,7 +98,10 @@ class MicropubMediaController extends Controller ], 400); } - if ($request->file('file')->isValid() === false) { + /** @var UploadedFile $file */ + $file = $request->file('file'); + + if ($file->isValid() === false) { return response()->json([ 'response' => 'error', 'error' => 'invalid_request', @@ -139,31 +109,25 @@ class MicropubMediaController extends Controller ], 400); } - $filename = $this->saveFile($request->file('file')); + $filename = Storage::disk('local')->putFile('media', $file); + /** @var ImageManager $manager */ $manager = resolve(ImageManager::class); try { - $image = $manager->make($request->file('file')); + $image = $manager->read($request->file('file')); $width = $image->width(); - } catch (NotReadableException $exception) { + } catch (Exception) { // not an image $width = null; } $media = Media::create([ - 'token' => $request->bearerToken(), - 'path' => 'media/' . $filename, + 'token' => $request->input('access_token'), + 'path' => $filename, 'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()), 'image_widths' => $width, ]); - // put the file on S3 initially, the ProcessMedia job may edit this - Storage::disk('s3')->putFileAs( - 'media', - new File(storage_path('app') . '/' . $filename), - $filename - ); - ProcessMedia::dispatch($filename); return response()->json([ @@ -185,7 +149,7 @@ class MicropubMediaController extends Controller */ private function getFileTypeFromMimeType(string $mimeType): string { - //try known images + // try known images $imageMimeTypes = [ 'image/gif', 'image/jpeg', @@ -197,7 +161,7 @@ class MicropubMediaController extends Controller if (in_array($mimeType, $imageMimeTypes)) { return 'image'; } - //try known video + // try known video $videoMimeTypes = [ 'video/mp4', 'video/mpeg', @@ -208,7 +172,7 @@ class MicropubMediaController extends Controller if (in_array($mimeType, $videoMimeTypes)) { return 'video'; } - //try known audio types + // try known audio types $audioMimeTypes = [ 'audio/midi', 'audio/mpeg', @@ -227,7 +191,7 @@ class MicropubMediaController extends Controller * * @throws Exception */ - private function saveFile(UploadedFile $file): string + private function saveFileToLocal(UploadedFile $file): string { $filename = Uuid::uuid4()->toString() . '.' . $file->extension(); Storage::disk('local')->putFileAs('', $file, $filename); diff --git a/app/Http/Controllers/NotesController.php b/app/Http/Controllers/NotesController.php index aea2bd76..d5c9bc90 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -5,32 +5,33 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Note; -use App\Services\ActivityStreamsService; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\View\View; use Jonnybarnes\IndieWeb\Numbers; -// Need to sort out Twitter and webmentions! - +/** + * @todo Need to sort out Twitter and webmentions! + */ class NotesController extends Controller { /** * Show all the notes. This is also the homepage. */ - public function index(Request $request): View|Response + public function index(): View|Response { - if ($request->wantsActivityStream()) { - return (new ActivityStreamsService())->siteOwnerResponse(); - } - $notes = Note::latest() ->with('place', 'media', 'client') - ->withCount(['webmentions As replies' => function ($query) { + ->withCount(['webmentions AS replies' => function ($query) { $query->where('type', 'in-reply-to'); + }]) + ->withCount(['webmentions AS likes' => function ($query) { + $query->where('type', 'like-of'); + }]) + ->withCount(['webmentions AS reposts' => function ($query) { + $query->where('type', 'repost-of'); }])->paginate(10); return view('notes.index', compact('notes')); @@ -42,15 +43,20 @@ class NotesController extends Controller public function show(string $urlId): View|JsonResponse|Response { try { - $note = Note::nb60($urlId)->with('webmentions')->firstOrFail(); + $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); } - if (request()->wantsActivityStream()) { - return (new ActivityStreamsService())->singleNoteResponse($note); - } - return view('notes.show', compact('note')); } @@ -59,7 +65,7 @@ class NotesController extends Controller */ public function redirect(int $decId): RedirectResponse { - return redirect(config('app.url') . '/notes/' . (new Numbers())->numto60($decId)); + return redirect(config('app.url') . '/notes/' . (new Numbers)->numto60($decId)); } /** diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 1ffb9e26..3f366538 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -17,7 +17,16 @@ class SearchController extends Controller /** @var Note $note */ foreach ($notes as $note) { - $note->load('place', 'media', 'client'); + $note->load('place', 'media', 'client') + ->loadCount(['webmentions AS replies' => function ($query) { + $query->where('type', 'in-reply-to'); + }]) + ->loadCount(['webmentions AS likes' => function ($query) { + $query->where('type', 'like-of'); + }]) + ->loadCount(['webmentions AS reposts' => function ($query) { + $query->where('type', 'repost-of'); + }]); } return view('search', compact('search', 'notes')); diff --git a/app/Http/Controllers/ShortURLsController.php b/app/Http/Controllers/ShortURLsController.php deleted file mode 100644 index ae8128f0..00000000 --- a/app/Http/Controllers/ShortURLsController.php +++ /dev/null @@ -1,52 +0,0 @@ -client = $client; - $this->guzzle = $guzzle; - $this->tokenService = $tokenService; - } - - /** - * If the user has auth’d via the IndieAuth protocol, issue a valid token. - */ - public function create(Request $request): JsonResponse - { - $auth = $this->verifyIndieAuthCode( - config('app.authorization_endpoint'), - $request->input('code'), - $request->input('redirect_uri'), - $request->input('client_id'), - ); - - if ($auth === null || ! array_key_exists('me', $auth)) { - return response()->json([ - 'error' => 'There was an error verifying the IndieAuth code', - ], 401); - } - - $scope = $auth['scope'] ?? ''; - $tokenData = [ - 'me' => config('app.url'), - 'client_id' => $request->input('client_id'), - 'scope' => $scope, - ]; - $token = $this->tokenService->getNewToken($tokenData); - $content = [ - 'me' => config('app.url'), - 'scope' => $scope, - 'access_token' => $token, - ]; - - return response()->json($content); - } - - protected function verifyIndieAuthCode( - string $authorizationEndpoint, - string $code, - string $redirectUri, - string $clientId - ): ?array { - try { - $response = $this->guzzle->request('POST', $authorizationEndpoint, [ - 'headers' => [ - 'Accept' => 'application/json', - ], - 'form_params' => [ - 'code' => $code, - 'me' => config('app.url'), - 'redirect_uri' => $redirectUri, - 'client_id' => $clientId, - ], - ]); - } catch (BadResponseException) { - return null; - } - - try { - $authData = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException) { - return null; - } - - return $authData; - } -} diff --git a/app/Http/Controllers/WebMentionsController.php b/app/Http/Controllers/WebMentionsController.php index b6c4a71b..49eac9b2 100644 --- a/app/Http/Controllers/WebMentionsController.php +++ b/app/Http/Controllers/WebMentionsController.php @@ -30,7 +30,7 @@ class WebMentionsController extends Controller */ public function receive(Request $request): Response { - //first we trivially reject requests that lack all required inputs + // first we trivially reject requests that lack all required inputs if (($request->has('target') !== true) || ($request->has('source') !== true)) { return response( 'You need both the target and source parameters', @@ -38,12 +38,12 @@ class WebMentionsController extends Controller ); } - //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 + // we have a note $noteId = $pathParts[2]; try { $note = Note::findOrFail(resolve(Numbers::class)->b60tonum($noteId)); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php deleted file mode 100644 index d2be1cb7..00000000 --- a/app/Http/Kernel.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ - protected $middleware = [ - // \App\Http\Middleware\TrustHosts::class, - \App\Http\Middleware\TrustProxies::class, - \Illuminate\Http\Middleware\HandleCors::class, - \App\Http\Middleware\PreventRequestsDuringMaintenance::class, - \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, - \App\Http\Middleware\TrimStrings::class, - \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, - ]; - - /** - * The application's route middleware groups. - * - * @var array> - */ - protected $middlewareGroups = [ - 'web' => [ - \App\Http\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - // \Illuminate\Session\Middleware\AuthenticateSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \App\Http\Middleware\LinkHeadersMiddleware::class, - \App\Http\Middleware\LocalhostSessionMiddleware::class, - \App\Http\Middleware\ActivityStreamLinks::class, - \App\Http\Middleware\CSPHeader::class, - ], - - 'api' => [ - // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, - \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', - \Illuminate\Routing\Middleware\SubstituteBindings::class, - ], - ]; - - /** - * The application's middleware aliases. - * - * Aliases may be used to conveniently assign middleware to routes and groups. - * - * @var array - */ - protected $middlewareAliases = [ - 'auth' => \App\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, - 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, - 'can' => \Illuminate\Auth\Middleware\Authorize::class, - 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, - 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, - 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, - 'micropub.token' => \App\Http\Middleware\VerifyMicropubToken::class, - 'myauth' => \App\Http\Middleware\MyAuthMiddleware::class, - 'cors' => \App\Http\Middleware\CorsHeaders::class, - ]; -} diff --git a/app/Http/Middleware/ActivityStreamLinks.php b/app/Http/Middleware/ActivityStreamLinks.php deleted file mode 100644 index 47727d98..00000000 --- a/app/Http/Middleware/ActivityStreamLinks.php +++ /dev/null @@ -1,28 +0,0 @@ -path() === '/') { - $response->header('Link', '<' . config('app.url') . '>; rel="application/activity+json"', false); - } - if ($request->is('notes/*')) { - $response->header('Link', '<' . $request->url() . '>; rel="application/activity+json"', false); - } - - return $response; - } -} diff --git a/app/Http/Middleware/CSPHeader.php b/app/Http/Middleware/CSPHeader.php deleted file mode 100644 index 07e77e3a..00000000 --- a/app/Http/Middleware/CSPHeader.php +++ /dev/null @@ -1,46 +0,0 @@ -header( - 'Content-Security-Policy', - "default-src 'self'; " . - "style-src 'self' cloud.typography.com jonnybarnes.uk; " . - "img-src 'self' data: blob: https://pbs.twimg.com https://jbuk-media.s3-eu-west-1.amazonaws.com https://jbuk-media-dev.s3-eu-west-1.amazonaws.com https://secure.gravatar.com https://graph.facebook.com *.fbcdn.net https://*.cdninstagram.com https://*.4sqi.net https://upload.wikimedia.org; " . - "font-src 'self' data:; " . - "frame-src 'self' https://www.youtube.com blob:; " . - 'upgrade-insecure-requests; ' . - 'block-all-mixed-content; ' . - 'report-to csp-endpoint; ' . - 'report-uri https://jonnybarnes.report-uri.io/r/default/csp/enforce;' - )->header( - 'Report-To', - '{' . - "'url': 'https://jonnybarnes.report-uri.io/r/default/csp/enforce', " . - "'group': 'csp-endpoint', " . - "'max-age': 10886400" . - '}' - ); - // phpcs:enable Generic.Files.LineLength.TooLong - } -} diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index d2a1b4f6..467283db 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -14,10 +14,11 @@ class LinkHeadersMiddleware public function handle(Request $request, Closure $next): Response { $response = $next($request); - $response->header('Link', '; rel="authorization_endpoint"', false); - $response->header('Link', '<' . config('app.url') . '/api/token>; rel="token_endpoint"', false); - $response->header('Link', '<' . config('app.url') . '/api/post>; rel="micropub"', false); - $response->header('Link', '<' . config('app.url') . '/webmention>; rel="webmention"', false); + $response->header('Link', '<' . route('indieauth.metadata') . '>; rel="indieauth-metadata"', false); + $response->header('Link', '<' . route('indieauth.start') . '>; rel="authorization_endpoint"', false); + $response->header('Link', '<' . route('indieauth.token') . '>; rel="token_endpoint"', false); + $response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false); + $response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false); return $response; } diff --git a/app/Http/Middleware/LogMicropubRequest.php b/app/Http/Middleware/LogMicropubRequest.php new file mode 100644 index 00000000..a04e80de --- /dev/null +++ b/app/Http/Middleware/LogMicropubRequest.php @@ -0,0 +1,24 @@ +pushHandler(new StreamHandler(storage_path('logs/micropub.log'))); + $logger->debug('MicropubLog', $request->all()); + + return $next($request); + } +} diff --git a/app/Http/Middleware/MyAuthMiddleware.php b/app/Http/Middleware/MyAuthMiddleware.php index 57d0bfa3..b22e2b33 100644 --- a/app/Http/Middleware/MyAuthMiddleware.php +++ b/app/Http/Middleware/MyAuthMiddleware.php @@ -18,6 +18,8 @@ class MyAuthMiddleware { if (Auth::check() === false) { // they’re not logged in, so send them to login form + redirect()->setIntendedUrl($request->fullUrl()); + return redirect()->route('login'); } diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 00000000..093bf64a --- /dev/null +++ b/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,22 @@ + + */ + protected $except = [ + // 'fbclid', + // 'utm_campaign', + // 'utm_content', + // 'utm_medium', + // 'utm_source', + // 'utm_term', + ]; +} diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index 813350cf..33d2cb12 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -4,31 +4,78 @@ declare(strict_types=1); namespace App\Http\Middleware; +use App\Http\Responses\MicropubResponses; use Closure; use Illuminate\Http\Request; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Encoding\CannotDecodeContent; +use Lcobucci\JWT\Token; +use Lcobucci\JWT\Token\InvalidTokenStructure; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use Symfony\Component\HttpFoundation\Response; class VerifyMicropubToken { /** * Handle an incoming request. + * + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { + $rawToken = null; + if ($request->input('access_token')) { - return $next($request); + $rawToken = $request->input('access_token'); + } elseif ($request->bearerToken()) { + $rawToken = $request->bearerToken(); } - if ($request->bearerToken()) { - return $next($request->merge([ - 'access_token' => $request->bearerToken(), - ])); + if (! $rawToken) { + return response()->json([ + 'response' => 'error', + 'error' => 'unauthorized', + 'error_description' => 'No access token was provided in the request', + ], 401); } - return response()->json([ - 'response' => 'error', - 'error' => 'unauthorized', - 'error_description' => 'No access token was provided in the request', - ], 401); + try { + $tokenData = $this->validateToken($rawToken); + } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { + $micropubResponses = new MicropubResponses; + + return $micropubResponses->invalidTokenResponse(); + } + + if ($tokenData->claims()->has('scope') === false) { + $micropubResponses = new MicropubResponses; + + return $micropubResponses->tokenHasNoScopeResponse(); + } + + return $next($request->merge([ + 'access_token' => $rawToken, + 'token_data' => [ + 'me' => $tokenData->claims()->get('me'), + 'scope' => $tokenData->claims()->get('scope'), + 'client_id' => $tokenData->claims()->get('client_id'), + ], + ])); + } + + /** + * Check the token signature is valid. + */ + private function validateToken(string $bearerToken): Token + { + $config = resolve(Configuration::class); + + $token = $config->parser()->parse($bearerToken); + + $constraints = $config->validationConstraints(); + + $config->validator()->assert($token, ...$constraints); + + return $token; } } diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php new file mode 100644 index 00000000..d931f139 --- /dev/null +++ b/app/Http/Requests/MicropubRequest.php @@ -0,0 +1,106 @@ +micropubData; + } + + public function getType(): ?string + { + // Return consistent type regardless of input format + return $this->micropubData['type'] ?? null; + } + + protected function prepareForValidation(): void + { + // Normalize the request data based on content type + if ($this->isJson()) { + $this->normalizeMicropubJson(); + } else { + $this->normalizeMicropubForm(); + } + } + + private function normalizeMicropubJson(): void + { + $json = $this->json(); + if ($json === null) { + throw new \InvalidArgumentException('`isJson()` passed but there is no json data'); + } + + $data = $json->all(); + + // Convert JSON type (h-entry) to simple type (entry) + if (isset($data['type']) && is_array($data['type'])) { + $type = current($data['type']); + if (strpos($type, 'h-') === 0) { + $this->micropubData['type'] = substr($type, 2); + } + } + // Or set the type to update + elseif (isset($data['action']) && $data['action'] === 'update') { + $this->micropubData['type'] = 'update'; + } + + // Add in the token data + $this->micropubData['token_data'] = $data['token_data']; + + // Add h-entry values + $this->micropubData['content'] = Arr::get($data, 'properties.content.0'); + $this->micropubData['in-reply-to'] = Arr::get($data, 'properties.in-reply-to.0'); + $this->micropubData['published'] = Arr::get($data, 'properties.published.0'); + $this->micropubData['location'] = Arr::get($data, 'location'); + $this->micropubData['bookmark-of'] = Arr::get($data, 'properties.bookmark-of.0'); + $this->micropubData['like-of'] = Arr::get($data, 'properties.like-of.0'); + $this->micropubData['mp-syndicate-to'] = Arr::get($data, 'properties.mp-syndicate-to'); + + // Add h-card values + $this->micropubData['name'] = Arr::get($data, 'properties.name.0'); + $this->micropubData['description'] = Arr::get($data, 'properties.description.0'); + $this->micropubData['geo'] = Arr::get($data, 'properties.geo.0'); + + // Add checkin value + $this->micropubData['checkin'] = Arr::get($data, 'checkin'); + $this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0'); + } + + private function normalizeMicropubForm(): void + { + // Convert form h=entry to type=entry + if ($h = $this->input('h')) { + $this->micropubData['type'] = $h; + } + + // Add some fields to the micropub data with default null values + $this->micropubData['in-reply-to'] = null; + $this->micropubData['published'] = null; + $this->micropubData['location'] = null; + $this->micropubData['description'] = null; + $this->micropubData['geo'] = null; + $this->micropubData['latitude'] = null; + $this->micropubData['longitude'] = null; + + // Map form fields to micropub data + foreach ($this->except(['h', 'access_token']) as $key => $value) { + $this->micropubData[$key] = $value; + } + } +} diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php index 72f469e5..3c187dd4 100644 --- a/app/Jobs/DownloadWebMention.php +++ b/app/Jobs/DownloadWebMention.php @@ -24,8 +24,7 @@ class DownloadWebMention implements ShouldQueue */ public function __construct( protected string $source - ) { - } + ) {} /** * Execute the job. @@ -36,30 +35,30 @@ class DownloadWebMention implements ShouldQueue public function handle(Client $guzzle): void { $response = $guzzle->request('GET', $this->source); - //4XX and 5XX responses should get Guzzle to throw an exception, - //Laravel should catch and retry these automatically. + // 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(); + $filesystem = new FileSystem; $filename = storage_path('HTML') . '/' . $this->createFilenameFromURL($this->source); - //backup file first + // backup file first $filenameBackup = $filename . '.' . date('Y-m-d') . '.backup'; if ($filesystem->exists($filename)) { $filesystem->copy($filename, $filenameBackup); } - //check if base directory exists + // check if base directory exists if (! $filesystem->exists($filesystem->dirname($filename))) { $filesystem->makeDirectory( $filesystem->dirname($filename), - 0755, //mode - true //recursive + 0755, // mode + true // recursive ); } - //save new HTML + // save new HTML $filesystem->put( $filename, (string) $response->getBody() ); - //remove backup if the same + // remove backup if the same if ($filesystem->exists($filenameBackup)) { if ($filesystem->get($filename) === $filesystem->get($filenameBackup)) { $filesystem->delete($filenameBackup); diff --git a/app/Jobs/ProcessBookmark.php b/app/Jobs/ProcessBookmark.php index b1dffe8a..96f65e87 100644 --- a/app/Jobs/ProcessBookmark.php +++ b/app/Jobs/ProcessBookmark.php @@ -25,8 +25,7 @@ class ProcessBookmark implements ShouldQueue */ public function __construct( protected Bookmark $bookmark - ) { - } + ) {} /** * Execute the job. diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php index 3a2d6f62..3c6028a9 100644 --- a/app/Jobs/ProcessLike.php +++ b/app/Jobs/ProcessLike.php @@ -30,8 +30,7 @@ class ProcessLike implements ShouldQueue */ public function __construct( protected Like $like - ) { - } + ) {} /** * Execute the job. @@ -50,7 +49,7 @@ class ProcessLike implements ShouldQueue $this->like->content = $tweet->html; $this->like->save(); - //POSSE like + // POSSE like try { $client->request( 'POST', diff --git a/app/Jobs/ProcessMedia.php b/app/Jobs/ProcessMedia.php index f35bdd1a..b7f36648 100644 --- a/app/Jobs/ProcessMedia.php +++ b/app/Jobs/ProcessMedia.php @@ -10,7 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Storage; -use Intervention\Image\Exception\NotReadableException; +use Intervention\Image\Exceptions\DecoderException; use Intervention\Image\ImageManager; class ProcessMedia implements ShouldQueue @@ -25,43 +25,45 @@ class ProcessMedia implements ShouldQueue */ public function __construct( protected string $filename - ) { - } + ) {} /** * Execute the job. */ public function handle(ImageManager $manager): void { - //open file + // Load file + $file = Storage::disk('local')->get('media/' . $this->filename); + + // Open file try { - $image = $manager->make(storage_path('app') . '/' . $this->filename); - } catch (NotReadableException $exception) { + $image = $manager->read($file); + } catch (DecoderException) { // not an image; delete file and end job - unlink(storage_path('app') . '/' . $this->filename); + Storage::disk('local')->delete('media/' . $this->filename); return; } - //create smaller versions if necessary + + // Save the file publicly + Storage::disk('public')->put('media/' . $this->filename, $file); + + // Create smaller versions if necessary if ($image->width() > 1000) { $filenameParts = explode('.', $this->filename); $extension = array_pop($filenameParts); // the following achieves this data flow // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar - $basename = ltrim(array_reduce($filenameParts, function ($carry, $item) { - return $carry . '.' . $item; - }, ''), '.'); - $medium = $image->resize(1000, null, function ($constraint) { - $constraint->aspectRatio(); - }); - Storage::disk('s3')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode()); - $small = $image->resize(500, null, function ($constraint) { - $constraint->aspectRatio(); - }); - Storage::disk('s3')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); + $basename = trim(implode('.', $filenameParts), '.'); + + $medium = $image->resize(width: 1000); + Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode()); + + $small = $image->resize(width: 500); + Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); } - // now we can delete the locally saved image - unlink(storage_path('app') . '/' . $this->filename); + // Now we can delete the locally saved image + Storage::disk('local')->delete('media/' . $this->filename); } } diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index 6aacf29d..d92dfa18 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -30,8 +30,7 @@ class ProcessWebMention implements ShouldQueue public function __construct( protected Note $note, protected string $source - ) { - } + ) {} /** * Execute the job. @@ -45,7 +44,7 @@ class ProcessWebMention implements ShouldQueue try { $response = $guzzle->request('GET', $this->source); } catch (RequestException $e) { - throw new RemoteContentNotFoundException(); + throw new RemoteContentNotFoundException; } $this->saveRemoteContent((string) $response->getBody(), $this->source); $microformats = Mf2\parse((string) $response->getBody(), $this->source); @@ -54,7 +53,7 @@ class ProcessWebMention implements ShouldQueue // check webmention still references target // we try each type of mention (reply/like/repost) if ($webmention->type === 'in-reply-to') { - if ($parser->checkInReplyTo($microformats, $this->note->longurl) === false) { + if ($parser->checkInReplyTo($microformats, $this->note->uri) === false) { // it doesn’t so delete $webmention->delete(); @@ -68,7 +67,7 @@ class ProcessWebMention implements ShouldQueue return; } if ($webmention->type === 'like-of') { - if ($parser->checkLikeOf($microformats, $this->note->longurl) === false) { + if ($parser->checkLikeOf($microformats, $this->note->uri) === false) { // it doesn’t so delete $webmention->delete(); @@ -76,7 +75,7 @@ class ProcessWebMention implements ShouldQueue } // 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->longurl) === false) { + if ($parser->checkRepostOf($microformats, $this->note->uri) === false) { // it doesn’t so delete $webmention->delete(); @@ -86,13 +85,13 @@ class ProcessWebMention implements ShouldQueue }// foreach // no webmention in the db so create new one - $webmention = new WebMention(); + $webmention = new WebMention; $type = $parser->getMentionType($microformats); // throw error here? dispatch(new SaveProfileImage($microformats)); $webmention->source = $this->source; - $webmention->target = $this->note->longurl; + $webmention->target = $this->note->uri; $webmention->commentable_id = $this->note->id; - $webmention->commentable_type = 'App\Model\Note'; + $webmention->commentable_type = Note::class; $webmention->type = $type; $webmention->mf2 = json_encode($microformats); $webmention->save(); diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php index 4183d587..08152d5b 100644 --- a/app/Jobs/SaveProfileImage.php +++ b/app/Jobs/SaveProfileImage.php @@ -25,8 +25,7 @@ class SaveProfileImage implements ShouldQueue */ public function __construct( protected array $microformats - ) { - } + ) {} /** * Execute the job. @@ -42,7 +41,15 @@ class SaveProfileImage implements ShouldQueue $photo = Arr::get($author, 'properties.photo.0'); $home = Arr::get($author, 'properties.url.0'); - //dont save pbs.twimg.com links + 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' diff --git a/app/Jobs/SaveScreenshot.php b/app/Jobs/SaveScreenshot.php index c086276c..0e07efbd 100755 --- a/app/Jobs/SaveScreenshot.php +++ b/app/Jobs/SaveScreenshot.php @@ -23,8 +23,7 @@ class SaveScreenshot implements ShouldQueue */ public function __construct( protected Bookmark $bookmark - ) { - } + ) {} /** * Execute the job. diff --git a/app/Jobs/SendWebMentions.php b/app/Jobs/SendWebMentions.php index b481f69d..2ff5f2c6 100644 --- a/app/Jobs/SendWebMentions.php +++ b/app/Jobs/SendWebMentions.php @@ -27,8 +27,7 @@ class SendWebMentions implements ShouldQueue */ public function __construct( protected Note $note - ) { - } + ) {} /** * Execute the job. @@ -46,7 +45,7 @@ class SendWebMentions implements ShouldQueue $guzzle = resolve(Client::class); $guzzle->post($endpoint, [ 'form_params' => [ - 'source' => $this->note->longurl, + 'source' => $this->note->uri, 'target' => $url, ], ]); @@ -62,7 +61,7 @@ class SendWebMentions implements ShouldQueue public function discoverWebmentionEndpoint(string $url): ?string { // let’s not send webmentions to myself - if (parse_url($url, PHP_URL_HOST) === config('app.longurl')) { + if (parse_url($url, PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST)) { return null; } if (Str::startsWith($url, '/notes/tagged/')) { @@ -71,18 +70,17 @@ class SendWebMentions implements ShouldQueue $endpoint = null; - /** @var Client $guzzle */ $guzzle = resolve(Client::class); $response = $guzzle->get($url); - //check HTTP Headers for webmention endpoint + // check HTTP Headers for webmention endpoint $links = Header::parse($response->getHeader('Link')); foreach ($links as $link) { - if (mb_stristr($link['rel'], 'webmention')) { + if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { return $this->resolveUri(trim($link[0], '<>'), $url); } } - //failed to find a header so parse HTML + // failed to find a header so parse HTML $html = (string) $response->getBody(); $mf2 = new \Mf2\Parser($html, $url); @@ -110,7 +108,7 @@ class SendWebMentions implements ShouldQueue } $urls = []; - $dom = new \DOMDocument(); + $dom = new \DOMDocument; $dom->loadHTML($html); $anchors = $dom->getElementsByTagName('a'); foreach ($anchors as $anchor) { diff --git a/app/Jobs/SyndicateNoteToBluesky.php b/app/Jobs/SyndicateNoteToBluesky.php new file mode 100644 index 00000000..e815be34 --- /dev/null +++ b/app/Jobs/SyndicateNoteToBluesky.php @@ -0,0 +1,62 @@ +request( + 'POST', + 'https://brid.gy/micropub', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . config('bridgy.bluesky_token'), + ], + 'json' => [ + 'type' => ['h-entry'], + 'properties' => [ + 'content' => [$this->note->getRawOriginal('note')], + ], + ], + ] + ); + + // Parse for syndication URL + if ($response->getStatusCode() === 201) { + $this->note->bluesky_url = $response->getHeader('Location')[0]; + $this->note->save(); + } + } +} diff --git a/app/Jobs/SyndicateNoteToMastodon.php b/app/Jobs/SyndicateNoteToMastodon.php index 557006a4..b79c092c 100644 --- a/app/Jobs/SyndicateNoteToMastodon.php +++ b/app/Jobs/SyndicateNoteToMastodon.php @@ -22,8 +22,7 @@ class SyndicateNoteToMastodon implements ShouldQueue */ public function __construct( protected Note $note - ) { - } + ) {} /** * Execute the job. diff --git a/app/Models/Article.php b/app/Models/Article.php index 5b660076..bfbd5d51 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -58,10 +58,10 @@ class Article extends Model { return Attribute::get( get: function () { - $environment = new Environment(); - $environment->addExtension(new CommonMarkCoreExtension()); - $environment->addRenderer(FencedCode::class, new FencedCodeRenderer()); - $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer()); + $environment = new Environment; + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addRenderer(FencedCode::class, new FencedCodeRenderer); + $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer); $markdownConverter = new MarkdownConverter($environment); return $markdownConverter->convert($this->main)->getContent(); @@ -107,7 +107,7 @@ class Article extends Model /** * Scope a query to only include articles from a particular year/month. */ - public function scopeDate(Builder $query, int $year = null, int $month = null): Builder + public function scopeDate(Builder $query, ?int $year = null, ?int $month = null): Builder { if ($year === null) { return $query; diff --git a/app/Models/Bookmark.php b/app/Models/Bookmark.php index 29bd25ad..37027e40 100644 --- a/app/Models/Bookmark.php +++ b/app/Models/Bookmark.php @@ -26,7 +26,7 @@ class Bookmark extends Model return $this->belongsToMany('App\Models\Tag'); } - protected function longurl(): Attribute + protected function local_uri(): Attribute { return Attribute::get( get: fn () => config('app.url') . '/bookmarks/' . $this->id, diff --git a/app/Models/Media.php b/app/Models/Media.php index c4dd6d5c..3d923bed 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -33,7 +33,7 @@ class Media extends Model return $attributes['path']; } - return config('filesystems.disks.s3.url') . '/' . $attributes['path']; + return config('app.url') . '/storage/' . $attributes['path']; } ); } @@ -78,7 +78,7 @@ class Media extends Model $basename = $this->getBasename($path); $extension = $this->getExtension($path); - return config('filesystems.disks.s3.url') . '/' . $basename . '-' . $size . '.' . $extension; + return config('app.url') . '/storage/' . $basename . '-' . $size . '.' . $extension; } private function getBasename(string $path): string diff --git a/app/Models/Note.php b/app/Models/Note.php index a9778f0b..74533443 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Models; -use App\CommonMark\Generators\ContactMentionGenerator; -use App\CommonMark\Renderers\ContactMentionRenderer; +use App\CommonMark\Generators\MentionGenerator; +use App\CommonMark\Renderers\MentionRenderer; use Codebird\Codebird; use Exception; use GuzzleHttp\Client; @@ -111,7 +111,7 @@ class Note extends Model { if ($value !== null) { $normalized = normalizer_normalize($value, Normalizer::FORM_C); - if ($normalized === '') { //we don’t want to save empty strings to the db + if ($normalized === '') { // we don’t want to save empty strings to the db $normalized = null; } $this->attributes['note'] = $normalized; @@ -124,7 +124,7 @@ class Note extends Model public function getNoteAttribute(?string $value): ?string { if ($value === null && $this->place !== null) { - $value = '📍: ' . $this->place->name . ''; + $value = '📍: ' . $this->place->name . ''; } // if $value is still null, just return null @@ -144,17 +144,17 @@ class Note extends Model */ public function getContentAttribute(): string { - $note = $this->note; + $note = $this->getRawOriginal('note'); foreach ($this->media as $media) { if ($media->type === 'image') { - $note .= ''; + $note .= PHP_EOL . ''; } if ($media->type === 'audio') { - $note .= '