diff --git a/.env.dusk.testing b/.env.dusk.testing new file mode 100644 index 00000000..756f4074 --- /dev/null +++ b/.env.dusk.testing @@ -0,0 +1,14 @@ +APP_ENV=testing +APP_DEBUG=true +APP_KEY=base64:6DJhvZLVjE6dD4Cqrteh+6Z5vZlG+v/soCKcDHLOAH0= +APP_URL=http://localhost:8000 +APP_LONGURL=localhost +APP_SHORTURL=local + +DB_CONNECTION=travis + +CACHE_DRIVER=array +SESSION_DRIVER=file +QUEUE_DRIVER=sync + +SCOUT_DRIVER=pgsql diff --git a/.env.example b/.env.example index 4eb61db5..5b52d82b 100644 --- a/.env.example +++ b/.env.example @@ -2,22 +2,11 @@ APP_NAME=Laravel APP_ENV=local APP_KEY= APP_DEBUG=true -APP_TIMEZONE=UTC APP_URL=https://example.com - -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 +APP_LONGURL=example.com +APP_SHORTURL=examp.le LOG_CHANNEL=stack -LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug @@ -28,30 +17,22 @@ DB_DATABASE=laravel DB_USERNAME=root DB_PASSWORD= -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log +BROADCAST_DRIVER=log +CACHE_DRIVER=file FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -# CACHE_PREFIX= +QUEUE_CONNECTION=sync +SESSION_DRIVER=file +SESSION_LIFETIME=120 MEMCACHED_HOST=127.0.0.1 -REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_MAILER=smtp +MAIL_HOST=mailhog +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null @@ -64,7 +45,19 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false -VITE_APP_NAME="${APP_NAME}" +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}" ADMIN_USER=admin# pick something better, this is used for `/admin` ADMIN_PASS=password @@ -78,13 +71,21 @@ 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= - FLARE_KEY= - IGNITION_OPEN_AI_KEY= +FONT_LINK= + BRIDGY_MASTODON_TOKEN= diff --git a/.env.github b/.env.github new file mode 100644 index 00000000..0ef2b89b --- /dev/null +++ b/.env.github @@ -0,0 +1,70 @@ +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= +FLARE_KEY= + +FONT_LINK= + +BRIDGY_MASTODON_TOKEN= diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..8e72ef3e --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,38 @@ +parserOptions: + sourceType: 'module' + ecmaVersion: 'latest' +extends: 'eslint:recommended' +env: + browser: true + es6: true +ignorePatterns: + - webpack.config.js +rules: + indent: + - error + - 2 + linebreak-style: + - error + - unix + quotes: + - error + - single + semi: + - error + - always + no-console: + - error + - allow: + - warn + - error + no-await-in-loop: + - error + no-promise-executor-return: + - error + require-atomic-updates: + - error + max-nested-callbacks: + - error + - 3 + prefer-promise-reject-errors: + - error diff --git a/.gitattributes b/.gitattributes index 78f41d7a..fcb21d39 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,7 @@ *.html diff=html *.md diff=markdown *.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..47abe68c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +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 new file mode 100644 index 00000000..f66a77b4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,144 @@ +name: Deploy + +on: + workflow_dispatch: + release: + types: [published] + +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 }} {} + 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 new file mode 100644 index 00000000..cde7e397 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,68 @@ +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.2' + 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 new file mode 100644 index 00000000..e6340c67 --- /dev/null +++ b/.github/workflows/pint.yml @@ -0,0 +1,38 @@ +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.2' + + - 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 a0c2459a..5a9b11c9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /public/coverage /public/hot /public/files +/public/fonts /public/storage /storage/*.key /vendor @@ -20,5 +21,3 @@ yarn-error.log /.idea /.vscode ray.php -/public/gpg.key -/public/assets/img/favicon.png diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 00000000..9daadf16 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,9 @@ +php: + preset: laravel + disabled: + - no_unused_imports + finder: + not-name: + - index.php +js: true +css: true diff --git a/app/CommonMark/Generators/MentionGenerator.php b/app/CommonMark/Generators/ContactMentionGenerator.php similarity index 84% rename from app/CommonMark/Generators/MentionGenerator.php rename to app/CommonMark/Generators/ContactMentionGenerator.php index 2ac1a797..507f2a0f 100644 --- a/app/CommonMark/Generators/MentionGenerator.php +++ b/app/CommonMark/Generators/ContactMentionGenerator.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 MentionGenerator implements MentionGeneratorInterface +class ContactMentionGenerator implements MentionGeneratorInterface { public function generateMention(Mention $mention): ?AbstractInline { diff --git a/app/CommonMark/Renderers/ContactMentionRenderer.php b/app/CommonMark/Renderers/ContactMentionRenderer.php new file mode 100644 index 00000000..f227f121 --- /dev/null +++ b/app/CommonMark/Renderers/ContactMentionRenderer.php @@ -0,0 +1,24 @@ +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 deleted file mode 100644 index d970fac8..00000000 --- a/app/CommonMark/Renderers/MentionRenderer.php +++ /dev/null @@ -1,37 +0,0 @@ -getIdentifier())->first(); - - // If we have a contact, render a mini-hcard - if ($contact) { - // rendering a blade template to a string, so can’t be an HtmlElement - return trim(view('templates.mini-hcard', ['contact' => $contact])->render()); - } - - // Otherwise, check the link is to the Mastodon profile - $mentionText = $node->getIdentifier(); - $parts = explode('@', $mentionText); - - // This is not [@]handle@instance, so return a Twitter link - if (count($parts) === 1) { - return new HtmlElement('a', ['href' => 'https://twitter.com/' . $parts[0]], '@' . $mentionText); - } - - // Render the Mastodon profile link - return new HtmlElement('a', ['href' => 'https://' . $parts[1] . '/@' . $parts[0]], '@' . $mentionText); - } -} diff --git a/app/Console/Commands/CopyMediaToLocal.php b/app/Console/Commands/CopyMediaToLocal.php deleted file mode 100644 index 2e8d2bce..00000000 --- a/app/Console/Commands/CopyMediaToLocal.php +++ /dev/null @@ -1,69 +0,0 @@ -path; - - $this->info('Processing: ' . $filename); - - // If the file is already saved locally skip to next one - if (Storage::disk('local')->exists('public/' . $filename)) { - $this->info('File already exists locally, skipping'); - - continue; - } - - // Copy the file from S3 to the local filesystem - if (! Storage::disk('s3')->exists($filename)) { - $this->error('File does not exist on S3'); - - continue; - } - $contents = Storage::disk('s3')->get($filename); - Storage::disk('local')->put('public/' . $filename, $contents); - - // Copy -medium and -small versions if they exist - $filenameParts = explode('.', $filename); - $extension = array_pop($filenameParts); - $basename = trim(implode('.', $filenameParts), '.'); - $mediumFilename = $basename . '-medium.' . $extension; - $smallFilename = $basename . '-small.' . $extension; - if (Storage::disk('s3')->exists($mediumFilename)) { - Storage::disk('local')->put('public/' . $mediumFilename, Storage::disk('s3')->get($mediumFilename)); - } - if (Storage::disk('s3')->exists($smallFilename)) { - Storage::disk('local')->put('public/' . $smallFilename, Storage::disk('s3')->get($smallFilename)); - } - } - } -} diff --git a/app/Console/Commands/MigratePlaceDataFromPostgis.php b/app/Console/Commands/MigratePlaceDataFromPostgis.php index 8d5d2c92..e0026150 100644 --- a/app/Console/Commands/MigratePlaceDataFromPostgis.php +++ b/app/Console/Commands/MigratePlaceDataFromPostgis.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\DB; /** * @codeCoverageIgnore + * + * @psalm-suppress UnusedClass */ class MigratePlaceDataFromPostgis extends Command { diff --git a/app/Console/Commands/ParseCachedWebMentions.php b/app/Console/Commands/ParseCachedWebMentions.php index a6b29176..96d57332 100644 --- a/app/Console/Commands/ParseCachedWebMentions.php +++ b/app/Console/Commands/ParseCachedWebMentions.php @@ -9,6 +9,9 @@ use Illuminate\Console\Command; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\FileSystem\FileSystem; +/** + * @psalm-suppress UnusedClass + */ class ParseCachedWebMentions extends Command { /** @@ -34,7 +37,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/ReDownloadWebMentions.php b/app/Console/Commands/ReDownloadWebMentions.php index c6452ba9..b29e7da8 100644 --- a/app/Console/Commands/ReDownloadWebMentions.php +++ b/app/Console/Commands/ReDownloadWebMentions.php @@ -8,6 +8,9 @@ use App\Jobs\DownloadWebMention; use App\Models\WebMention; use Illuminate\Console\Command; +/** + * @psalm-suppress UnusedClass + */ class ReDownloadWebMentions extends Command { /** diff --git a/app/Console/Commands/UpdateWebmentionsRelationship.php b/app/Console/Commands/UpdateWebmentionsRelationship.php deleted file mode 100644 index f5bc1114..00000000 --- a/app/Console/Commands/UpdateWebmentionsRelationship.php +++ /dev/null @@ -1,36 +0,0 @@ -where('commentable_type', '=', 'App\Model\Note') - ->update(['commentable_type' => Note::class]); - - $this->info('All webmentions updated to relate to the correct note model class'); - } -} diff --git a/app/Exceptions/InternetArchiveException.php b/app/Exceptions/InternetArchiveException.php index 99d5cab7..7e810fea 100644 --- a/app/Exceptions/InternetArchiveException.php +++ b/app/Exceptions/InternetArchiveException.php @@ -2,4 +2,6 @@ namespace App\Exceptions; -class InternetArchiveException extends \Exception {} +class InternetArchiveException extends \Exception +{ +} diff --git a/app/Exceptions/InvalidTokenScopeException.php b/app/Exceptions/InvalidTokenScopeException.php deleted file mode 100644 index 5966bccd..00000000 --- a/app/Exceptions/InvalidTokenScopeException.php +++ /dev/null @@ -1,7 +0,0 @@ -hasFile('article')) { $file = request()->file('article')->openFile(); $content = $file->fread($file->getSize()); diff --git a/app/Http/Controllers/Admin/BioController.php b/app/Http/Controllers/Admin/BioController.php index c760e12c..8560eba9 100644 --- a/app/Http/Controllers/Admin/BioController.php +++ b/app/Http/Controllers/Admin/BioController.php @@ -10,6 +10,9 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class BioController extends Controller { public function show(): View diff --git a/app/Http/Controllers/Admin/ClientsController.php b/app/Http/Controllers/Admin/ClientsController.php index 38524b62..290da502 100644 --- a/app/Http/Controllers/Admin/ClientsController.php +++ b/app/Http/Controllers/Admin/ClientsController.php @@ -9,6 +9,9 @@ use App\Models\MicropubClient; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class ClientsController extends Controller { /** diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php index eb45320c..836c99cc 100644 --- a/app/Http/Controllers/Admin/ContactsController.php +++ b/app/Http/Controllers/Admin/ContactsController.php @@ -12,6 +12,9 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Support\Arr; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class ContactsController extends Controller { /** @@ -37,7 +40,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'); @@ -76,7 +79,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); } @@ -136,7 +139,7 @@ class ContactsController extends Controller } if ($avatar !== null) { $directory = public_path() . '/assets/profile-images/' . parse_url($contact->homepage, PHP_URL_HOST); - $filesystem = new Filesystem; + $filesystem = new Filesystem(); if ($filesystem->isDirectory($directory) === false) { $filesystem->makeDirectory($directory); } diff --git a/app/Http/Controllers/Admin/HomeController.php b/app/Http/Controllers/Admin/HomeController.php index ae4f4d36..d469c66c 100644 --- a/app/Http/Controllers/Admin/HomeController.php +++ b/app/Http/Controllers/Admin/HomeController.php @@ -7,6 +7,9 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class HomeController extends Controller { /** diff --git a/app/Http/Controllers/Admin/LikesController.php b/app/Http/Controllers/Admin/LikesController.php index 9ebd7e74..c8553348 100644 --- a/app/Http/Controllers/Admin/LikesController.php +++ b/app/Http/Controllers/Admin/LikesController.php @@ -10,6 +10,9 @@ use App\Models\Like; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class LikesController extends Controller { /** diff --git a/app/Http/Controllers/Admin/NotesController.php b/app/Http/Controllers/Admin/NotesController.php index c6ed93ba..afa75adb 100644 --- a/app/Http/Controllers/Admin/NotesController.php +++ b/app/Http/Controllers/Admin/NotesController.php @@ -11,6 +11,9 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class NotesController extends Controller { /** @@ -64,7 +67,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 index 9f635f10..5fdca622 100644 --- a/app/Http/Controllers/Admin/PasskeysController.php +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -18,8 +18,8 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; use ParagonIE\ConstantTime\Base64UrlSafe; -use Random\RandomException; use Throwable; +use Webauthn\AttestationStatement\AttestationObjectLoader; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\NoneAttestationStatementSupport; use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; @@ -28,17 +28,18 @@ use Webauthn\AuthenticatorAssertionResponseValidator; use Webauthn\AuthenticatorAttestationResponse; use Webauthn\AuthenticatorAttestationResponseValidator; use Webauthn\AuthenticatorSelectionCriteria; -use Webauthn\CeremonyStep\CeremonyStepManagerFactory; -use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\Exception\WebauthnException; -use Webauthn\PublicKeyCredential; use Webauthn\PublicKeyCredentialCreationOptions; +use Webauthn\PublicKeyCredentialLoader; use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; +/** + * @psalm-suppress UnusedClass + */ class PasskeysController extends Controller { public function index(): View @@ -50,26 +51,22 @@ class PasskeysController extends Controller return view('admin.passkeys.index', compact('passkeys')); } - /** - * @throws RandomException - * @throws \JsonException - */ - public function getCreateOptions(Request $request): JsonResponse + public function getCreateOptions(): JsonResponse { /** @var User $user */ $user = auth()->user(); // RP Entity i.e. the application $rpEntity = PublicKeyCredentialRpEntity::create( - name: config('app.name'), - id: config('app.url'), + config('app.name'), + config('url.longurl'), ); // User Entity $userEntity = PublicKeyCredentialUserEntity::create( - name: $user->name, - id: (string) $user->id, - displayName: $user->name, + $user->name, + (string) $user->id, + $user->name, ); // Challenge @@ -87,100 +84,70 @@ class PasskeysController extends Controller $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create( userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED, residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED, + requireResidentKey: true, ); - $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::create( - rp: $rpEntity, - user: $userEntity, - challenge: $challenge, - pubKeyCredParams: $pubKeyCredParams, + $options = PublicKeyCredentialCreationOptions::create( + $rpEntity, + $userEntity, + $challenge, + $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' - ); + $options = json_encode($options, JSON_THROW_ON_ERROR); - $request->session()->put('create_options', $publicKeyCredentialCreationOptions); + session(['create_options' => $options]); - return JsonResponse::fromJsonString($publicKeyCredentialCreationOptions); + return JsonResponse::fromJsonString($options); } - /** - * @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'); } + $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromString($publicKeyCredentialCreationOptionsData); - $attestationStatementSupportManager = new AttestationStatementSupportManager; - $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); - $webauthnSerializerFactory = new WebauthnSerializerFactory( - attestationStatementSupportManager: $attestationStatementSupportManager - ); - $webauthnSerializer = $webauthnSerializerFactory->create(); + // Unset session data to mitigate replay attacks + session()->forget('create_options'); - $publicKeyCredential = $webauthnSerializer->deserialize( - json_encode($request->all(), JSON_THROW_ON_ERROR), - PublicKeyCredential::class, - 'json' - ); + $attestationSupportManager = AttestationStatementSupportManager::create(); + $attestationSupportManager->add(NoneAttestationStatementSupport::create()); + $attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager); + $publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader); + + $publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR)); 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); + $attestationStatementSupportManager = AttestationStatementSupportManager::create(); + $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create()); $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( - ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony() + attestationStatementSupportManager: $attestationStatementSupportManager, + publicKeyCredentialSourceRepository: null, + tokenBindingHandler: null, + extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(), ); - $publicKeyCredentialCreationOptions = $webauthnSerializer->deserialize( - $publicKeyCredentialCreationOptionsData, - PublicKeyCredentialCreationOptions::class, - 'json' - ); + $securedRelyingPartyId = []; + if (App::environment('local', 'development')) { + $securedRelyingPartyId = [config('url.longurl')]; + } $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( authenticatorAttestationResponse: $publicKeyCredential->response, publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions, - host: config('app.url') + request: config('url.longurl'), + securedRelyingPartyId: $securedRelyingPartyId, ); $user->passkey()->create([ @@ -194,37 +161,24 @@ class PasskeysController extends Controller ]); } - /** - * @throws RandomException - * @throws \JsonException - */ - public function getRequestOptions(Request $request): JsonResponse + public function getRequestOptions(): 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'); + $publicKeyCredentialRequestOptions = json_encode($publicKeyCredentialRequestOptions, JSON_THROW_ON_ERROR); - $request->session()->put('request_options', $publicKeyCredentialRequestOptions); + session(['request_options' => $publicKeyCredentialRequestOptions]); return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions); } - /** - * @throws \JsonException - */ public function login(Request $request): JsonResponse { $requestOptions = session('request_options'); - $request->session()->forget('request_options'); + session()->forget('request_options'); if (empty($requestOptions)) { return response()->json([ @@ -233,19 +187,14 @@ class PasskeysController extends Controller ], 400); } - $attestationStatementSupportManager = new AttestationStatementSupportManager; - $attestationStatementSupportManager->add(new NoneAttestationStatementSupport); + $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($requestOptions); - $webauthnSerializerFactory = new WebauthnSerializerFactory( - attestationStatementSupportManager: $attestationStatementSupportManager - ); - $webauthnSerializer = $webauthnSerializerFactory->create(); + $attestationSupportManager = AttestationStatementSupportManager::create(); + $attestationSupportManager->add(NoneAttestationStatementSupport::create()); + $attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager); + $publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader); - $publicKeyCredential = $webauthnSerializer->deserialize( - json_encode($request->all(), JSON_THROW_ON_ERROR), - PublicKeyCredential::class, - 'json' - ); + $publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR)); if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) { return response()->json([ @@ -262,51 +211,33 @@ class PasskeysController extends Controller ], 404); } - $publicKeyCredentialSource = $webauthnSerializer->deserialize( - $passkey->passkey, - PublicKeyCredentialSource::class, - 'json' + $credential = PublicKeyCredentialSource::createFromArray(json_decode($passkey->passkey, true, 512, JSON_THROW_ON_ERROR)); + + $algorithmManager = Manager::create(); + $algorithmManager->add(new Ed25519()); + $algorithmManager->add(new ES256()); + $algorithmManager->add(new RS256()); + + $authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator( + publicKeyCredentialSourceRepository: null, + tokenBindingHandler: null, + extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(), + algorithmManager: $algorithmManager, ); - $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 = []; + $securedRelyingPartyId = []; if (App::environment('local', 'development')) { - $allowedOrigins = [config('app.url')]; + $securedRelyingPartyId = [config('url.longurl')]; } - $ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins); - - $authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create( - ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony() - ); - - $publicKeyCredentialRequestOptions = $webauthnSerializer->deserialize( - $requestOptions, - PublicKeyCredentialRequestOptions::class, - 'json' - ); try { $authenticatorAssertionResponseValidator->check( - publicKeyCredentialSource: $publicKeyCredentialSource, + credentialId: $credential, authenticatorAssertionResponse: $publicKeyCredential->response, publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions, - host: config('app.url'), + request: config('url.longurl'), userHandle: null, + securedRelyingPartyId: $securedRelyingPartyId, ); } catch (Throwable) { return response()->json([ diff --git a/app/Http/Controllers/Admin/PlacesController.php b/app/Http/Controllers/Admin/PlacesController.php index e5e82bcd..2b0d2e99 100644 --- a/app/Http/Controllers/Admin/PlacesController.php +++ b/app/Http/Controllers/Admin/PlacesController.php @@ -10,6 +10,9 @@ use App\Services\PlaceService; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class PlacesController extends Controller { protected PlaceService $placeService; diff --git a/app/Http/Controllers/Admin/SyndicationTargetsController.php b/app/Http/Controllers/Admin/SyndicationTargetsController.php index dc14a2d2..6eb60f69 100644 --- a/app/Http/Controllers/Admin/SyndicationTargetsController.php +++ b/app/Http/Controllers/Admin/SyndicationTargetsController.php @@ -10,6 +10,9 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class SyndicationTargetsController extends Controller { /** diff --git a/app/Http/Controllers/ArticlesController.php b/app/Http/Controllers/ArticlesController.php index 9ab860d7..a7abed25 100644 --- a/app/Http/Controllers/ArticlesController.php +++ b/app/Http/Controllers/ArticlesController.php @@ -10,12 +10,15 @@ use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use Jonnybarnes\IndieWeb\Numbers; +/** + * @psalm-suppress UnusedClass + */ 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) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index bd0022d6..27f34eab 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -9,6 +9,9 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class AuthController extends Controller { /** diff --git a/app/Http/Controllers/BookmarksController.php b/app/Http/Controllers/BookmarksController.php index b4bb3c13..ae9a0280 100644 --- a/app/Http/Controllers/BookmarksController.php +++ b/app/Http/Controllers/BookmarksController.php @@ -7,6 +7,9 @@ namespace App\Http\Controllers; use App\Models\Bookmark; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class BookmarksController extends Controller { /** diff --git a/app/Http/Controllers/ContactsController.php b/app/Http/Controllers/ContactsController.php index 280cc3ed..503a75ff 100644 --- a/app/Http/Controllers/ContactsController.php +++ b/app/Http/Controllers/ContactsController.php @@ -8,6 +8,9 @@ use App\Models\Contact; use Illuminate\Filesystem\Filesystem; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class ContactsController extends Controller { /** @@ -15,7 +18,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 +40,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 8677cd5c..77ec359a 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,11 @@ namespace App\Http\Controllers; -abstract class Controller +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Routing\Controller as BaseController; + +class Controller extends BaseController { - // + use AuthorizesRequests, ValidatesRequests; } diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index eb0847a3..c2738378 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -9,6 +9,9 @@ use App\Models\Note; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; +/** + * @psalm-suppress UnusedClass + */ class FeedsController extends Controller { /** @@ -70,16 +73,10 @@ class FeedsController extends Controller { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1.1', + 'version' => 'https://jsonfeed.org/version/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' => [], ]; @@ -91,6 +88,9 @@ class FeedsController extends Controller 'content_html' => $article->main, 'date_published' => $article->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(), + 'author' => [ + 'name' => config('user.display_name'), + ], ]; } @@ -102,32 +102,26 @@ class FeedsController extends Controller */ public function notesJson(): array { - $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); + $notes = Note::latest()->with('media')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1.1', + 'version' => 'https://jsonfeed.org/version/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->uri, - 'url' => $note->uri, - 'content_text' => $note->content, + 'id' => $note->longurl, + 'url' => $note->longurl, + 'content_html' => $note->content, 'date_published' => $note->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $note->updated_at->tz('UTC')->toRfc3339String(), + 'author' => [ + 'name' => config('user.display_name'), + ], ]; - if ($note->tags->count() > 0) { - $data['items'][$key]['tags'] = implode(',', $note->tags->pluck('tag')->toArray()); - } } return $data; @@ -161,7 +155,7 @@ class FeedsController extends Controller 'author' => [ 'type' => 'card', 'name' => config('user.display_name'), - 'url' => config('app.url'), + 'url' => config('url.longurl'), ], 'children' => $items, ], 200, [ @@ -180,8 +174,8 @@ class FeedsController extends Controller $items[] = [ 'type' => 'entry', 'published' => $note->created_at, - 'uid' => $note->uri, - 'url' => $note->uri, + 'uid' => $note->longurl, + 'url' => $note->longurl, 'content' => [ 'text' => $note->getRawOriginal('note'), 'html' => $note->note, @@ -197,7 +191,7 @@ class FeedsController extends Controller 'author' => [ 'type' => 'card', 'name' => config('user.display_name'), - 'url' => config('app.url'), + 'url' => config('url.longurl'), ], 'children' => $items, ], 200, [ diff --git a/app/Http/Controllers/FrontPageController.php b/app/Http/Controllers/FrontPageController.php index 19537663..191887a9 100644 --- a/app/Http/Controllers/FrontPageController.php +++ b/app/Http/Controllers/FrontPageController.php @@ -10,6 +10,9 @@ use App\Models\Note; use Illuminate\Http\Response; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class FrontPageController extends Controller { /** @@ -17,17 +20,9 @@ class FrontPageController extends Controller */ public function index(): Response|View { - $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(); + $notes = Note::latest()->with(['media', 'client', 'place'])->get(); $articles = Article::latest()->get(); - $bookmarks = Bookmark::latest()->with('tags')->get(); + $bookmarks = Bookmark::latest()->get(); $likes = Like::latest()->get(); $items = collect($notes) diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php deleted file mode 100644 index 45b488da..00000000 --- a/app/Http/Controllers/IndieAuthController.php +++ /dev/null @@ -1,327 +0,0 @@ -json([ - 'issuer' => config('app.url'), - 'authorization_endpoint' => route('indieauth.start'), - 'token_endpoint' => route('indieauth.token'), - 'code_challenge_methods_supported' => ['S256'], - // 'introspection_endpoint' => route('indieauth.introspection'), - // 'introspection_endpoint_auth_methods_supported' => ['none'], - ]); - } - - /** - * Process a GET request to the IndieAuth endpoint. - * - * This is the first step in the IndieAuth flow, where the client app sends the user to the IndieAuth endpoint. - */ - public function start(Request $request): View - { - // First check all required params are present - $validator = Validator::make($request->all(), [ - 'response_type' => 'required:string', - 'client_id' => 'required', - 'redirect_uri' => 'required', - 'state' => 'required', - 'code_challenge' => 'required:string', - 'code_challenge_method' => 'required:string', - ], [ - 'response_type' => 'response_type is required', - 'client_id.required' => 'client_id is required to display which app is asking for authentication', - 'redirect_uri.required' => 'redirect_uri is required so we can progress successful requests', - 'state.required' => 'state is required', - 'code_challenge.required' => 'code_challenge is required', - 'code_challenge_method.required' => 'code_challenge_method is required', - ]); - - if ($validator->fails()) { - return view('indieauth.error')->withErrors($validator); - } - - if ($request->get('response_type') !== 'code') { - return view('indieauth.error')->withErrors(['response_type' => 'only a response_type of "code" is supported']); - } - - if (mb_strtoupper($request->get('code_challenge_method')) !== 'S256') { - return view('indieauth.error')->withErrors(['code_challenge_method' => 'only a code_challenge_method of "S256" is supported']); - } - - if (! $this->isValidRedirectUri($request->get('client_id'), $request->get('redirect_uri'))) { - return view('indieauth.error')->withErrors(['redirect_uri' => 'redirect_uri is not valid for this client_id']); - } - - $scopes = $request->get('scope', ''); - $scopes = explode(' ', $scopes); - - return view('indieauth.start', [ - 'me' => $request->get('me'), - 'client_id' => $request->get('client_id'), - 'redirect_uri' => $request->get('redirect_uri'), - 'state' => $request->get('state'), - 'scopes' => $scopes, - 'code_challenge' => $request->get('code_challenge'), - 'code_challenge_method' => $request->get('code_challenge_method'), - ]); - } - - /** - * Confirm an IndieAuth approval request. - * - * Generates an auth code and redirects the user back to the client app. - * - * @throws RandomException - */ - public function confirm(Request $request): RedirectResponse - { - $authCode = bin2hex(random_bytes(16)); - - $cacheKey = hash('xxh3', $request->get('client_id')); - - $indieAuthRequestData = [ - 'code_challenge' => $request->get('code_challenge'), - 'code_challenge_method' => $request->get('code_challenge_method'), - 'client_id' => $request->get('client_id'), - 'redirect_uri' => $request->get('redirect_uri'), - 'auth_code' => $authCode, - 'scope' => implode(' ', $request->get('scope', '')), - ]; - - Cache::put($cacheKey, $indieAuthRequestData, now()->addMinutes(10)); - - $redirectUri = new Uri($request->get('redirect_uri')); - $redirectUri = Uri::withQueryValues($redirectUri, [ - 'code' => $authCode, - 'state' => $request->get('state'), - 'iss' => config('app.url'), - ]); - - return redirect()->away($redirectUri); - } - - /** - * Process a POST request to the IndieAuth auth endpoint. - * - * This is one possible second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth - * endpoint. As it is to the auth endpoint we return profile information. A similar request can be made to the token - * endpoint to get an access token. - */ - public function processCodeExchange(Request $request): JsonResponse - { - $invalidCodeResponse = $this->validateAuthorizationCode($request); - - if ($invalidCodeResponse instanceof JsonResponse) { - return $invalidCodeResponse; - } - - return response()->json([ - 'me' => config('app.url'), - ]); - } - - /** - * Process a POST request to the IndieAuth token endpoint. - * - * This is another possible second step in the IndieAuth flow, where the client app sends the auth code to the - * IndieAuth token endpoint. As it is to the token endpoint we return an access token. - * - * @throws SodiumException - */ - public function processTokenRequest(Request $request): JsonResponse - { - $indieAuthData = $this->validateAuthorizationCode($request); - - if ($indieAuthData instanceof JsonResponse) { - return $indieAuthData; - } - - if ($indieAuthData['scope'] === '') { - return response()->json(['errors' => [ - 'scope' => [ - 'The scope property must be non-empty for an access token to be issued.', - ], - ]], 400); - } - - $tokenData = [ - 'me' => config('app.url'), - 'client_id' => $request->get('client_id'), - 'scope' => $indieAuthData['scope'], - ]; - $tokenService = resolve(TokenService::class); - $token = $tokenService->getNewToken($tokenData); - - return response()->json([ - 'access_token' => $token, - 'token_type' => 'Bearer', - 'scope' => $indieAuthData['scope'], - 'me' => config('app.url'), - ]); - } - - protected function isValidRedirectUri(string $clientId, string $redirectUri): bool - { - // If client_id is not a valid URL, then it's not valid - $clientIdParsed = \Mf2\parseUriToComponents($clientId); - if (! isset($clientIdParsed['authority'])) { - return false; - } - - // If redirect_uri is not a valid URL, then it's not valid - $redirectUriParsed = \Mf2\parseUriToComponents($redirectUri); - if (! isset($redirectUriParsed['authority'])) { - return false; - } - - // If client_id and redirect_uri are the same host, then it's valid - if ($clientIdParsed['authority'] === $redirectUriParsed['authority']) { - return true; - } - - // Otherwise we need to check the redirect_uri is in the client_id's redirect_uris - $guzzle = resolve(Client::class); - - try { - $clientInfo = $guzzle->get($clientId); - } catch (Exception) { - return false; - } - - $clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId); - - $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; - - return in_array($redirectUri, $redirectUris, true); - } - - /** - * @throws SodiumException - */ - protected function validateAuthorizationCode(Request $request): JsonResponse|array - { - // First check all the data is present - $validator = Validator::make($request->all(), [ - 'grant_type' => 'required:string', - 'code' => 'required:string', - 'client_id' => 'required', - 'redirect_uri' => 'required', - 'code_verifier' => 'required', - ]); - - if ($validator->fails()) { - return response()->json(['errors' => $validator->errors()], 400); - } - - if ($request->get('grant_type') !== 'authorization_code') { - return response()->json(['errors' => [ - 'grant_type' => [ - 'Only a grant type of "authorization_code" is supported.', - ], - ]], 400); - } - - // Check cache for auth code - $cacheKey = hash('xxh3', $request->get('client_id')); - $indieAuthRequestData = Cache::pull($cacheKey); - - if ($indieAuthRequestData === null) { - return response()->json(['errors' => [ - 'code' => [ - 'The code is invalid.', - ], - ]], 404); - } - - // Check the IndieAuth code - if (! array_key_exists('auth_code', $indieAuthRequestData)) { - return response()->json(['errors' => [ - 'code' => [ - 'The code is invalid.', - ], - ]], 400); - } - if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { - return response()->json(['errors' => [ - 'code' => [ - 'The code is invalid.', - ], - ]], 400); - } - - // Check code verifier - if (! array_key_exists('code_challenge', $indieAuthRequestData)) { - return response()->json(['errors' => [ - 'code_verifier' => [ - 'The code verifier is invalid.', - ], - ]], 400); - } - if (! hash_equals( - $indieAuthRequestData['code_challenge'], - sodium_bin2base64( - hash('sha256', $request->get('code_verifier'), true), - SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING - ) - )) { - return response()->json(['errors' => [ - 'code_verifier' => [ - 'The code verifier is invalid.', - ], - ]], 400); - } - - // Check redirect_uri - if (! array_key_exists('redirect_uri', $indieAuthRequestData)) { - return response()->json(['errors' => [ - 'redirect_uri' => [ - 'The redirect uri is invalid.', - ], - ]], 400); - } - if ($indieAuthRequestData['redirect_uri'] !== $request->get('redirect_uri')) { - return response()->json(['errors' => [ - 'redirect_uri' => [ - 'The redirect uri is invalid.', - ], - ]], 400); - } - - // Check client_id - if (! array_key_exists('client_id', $indieAuthRequestData)) { - return response()->json(['errors' => [ - 'client_id' => [ - 'The client id is invalid.', - ], - ]], 400); - } - if ($indieAuthRequestData['client_id'] !== $request->get('client_id')) { - return response()->json(['errors' => [ - 'client_id' => [ - 'The client id is invalid.', - ], - ]], 400); - } - - return $indieAuthRequestData; - } -} diff --git a/app/Http/Controllers/LikesController.php b/app/Http/Controllers/LikesController.php index af1c483c..77d5f963 100644 --- a/app/Http/Controllers/LikesController.php +++ b/app/Http/Controllers/LikesController.php @@ -7,6 +7,9 @@ namespace App\Http\Controllers; use App\Models\Like; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class LikesController extends Controller { /** diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 758b3255..629cb4e6 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -4,73 +4,110 @@ declare(strict_types=1); namespace App\Http\Controllers; -use App\Exceptions\InvalidTokenScopeException; -use App\Exceptions\MicropubHandlerException; -use App\Http\Requests\MicropubRequest; +use App\Http\Responses\MicropubResponses; use App\Models\Place; use App\Models\SyndicationTarget; -use App\Services\Micropub\MicropubHandlerRegistry; +use App\Services\Micropub\HCardService; +use App\Services\Micropub\HEntryService; +use App\Services\Micropub\UpdateService; +use App\Services\TokenService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Lcobucci\JWT\Token; +use Lcobucci\JWT\Encoding\CannotDecodeContent; +use Lcobucci\JWT\Token\InvalidTokenStructure; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +/** + * @psalm-suppress UnusedClass + */ class MicropubController extends Controller { - protected MicropubHandlerRegistry $handlerRegistry; + protected TokenService $tokenService; - public function __construct(MicropubHandlerRegistry $handlerRegistry) - { - $this->handlerRegistry = $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; } /** - * 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. + * This function receives an API request, verifies the authenticity + * then passes over the info to the relevant Service class. */ - public function post(MicropubRequest $request): JsonResponse + public function post(Request $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 { - $handler = $this->handlerRegistry->getHandler($type); - $result = $handler->handle($request->getMicropubData()); + $tokenData = $this->tokenService->validateToken($request->input('access_token')); + } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { + $micropubResponses = new MicropubResponses(); - // Return appropriate response based on the handler result - return response()->json([ - 'response' => $result['response'], - 'location' => $result['url'] ?? null, - ], 201)->header('Location', $result['url']); - } catch (\InvalidArgumentException $e) { - return response()->json([ - 'error' => 'invalid_request', - 'error_description' => $e->getMessage(), - ], 400); - } catch (MicropubHandlerException) { - return response()->json([ - 'error' => 'Unknown Micropub type', - 'error_description' => 'The request could not be processed by this server', - ], 500); - } catch (InvalidTokenScopeException) { - return response()->json([ - 'error' => 'invalid_scope', - 'error_description' => 'The token does not have the required scope for this request', - ], 403); - } catch (\Exception) { - return response()->json([ - 'error' => 'server_error', - 'error_description' => 'An error occurred processing the request', - ], 500); + 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()); + + 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()); + + return response()->json([ + 'response' => 'created', + 'location' => $location, + ], 201)->header('Location', $location); + } + + 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); } /** @@ -83,6 +120,12 @@ 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(), @@ -114,17 +157,36 @@ class MicropubController extends Controller ]); } - // the default response is just to return the token data - /** @var Token $tokenData */ - $tokenData = $request->input('token_data'); - + // default response is just to return the token data return response()->json([ 'response' => 'token', 'token' => [ - 'me' => $tokenData['me'], - 'scope' => $tokenData['scope'], - 'client_id' => $tokenData['client_id'], + 'me' => $tokenData->claims()->get('me'), + 'scope' => $tokenData->claims()->get('scope'), + 'client_id' => $tokenData->claims()->get('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 fc804ea2..845f959c 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -7,29 +7,55 @@ 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; +/** + * @psalm-suppress UnusedClass + */ class MicropubMediaController extends Controller { + protected TokenService $tokenService; + + public function __construct(TokenService $tokenService) + { + $this->tokenService = $tokenService; + } + public function getHandler(Request $request): JsonResponse { - $tokenData = $request->input('token_data'); + try { + $tokenData = $this->tokenService->validateToken($request->input('access_token')); + } catch (RequiredConstraintsViolated|InvalidTokenStructure) { + $micropubResponses = new MicropubResponses(); - $scopes = $tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); + return $micropubResponses->invalidTokenResponse(); } - if (! in_array('create', $scopes, true)) { - return (new MicropubResponses)->insufficientScopeResponse(); + + 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 ($request->input('q') === 'last') { @@ -80,14 +106,24 @@ class MicropubMediaController extends Controller */ public function media(Request $request): JsonResponse { - $tokenData = $request->input('token_data'); + try { + $tokenData = $this->tokenService->validateToken($request->input('access_token')); + } catch (RequiredConstraintsViolated|InvalidTokenStructure $exception) { + $micropubResponses = new MicropubResponses(); - $scopes = $tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); + return $micropubResponses->invalidTokenResponse(); } - if (! in_array('create', $scopes, true)) { - return (new MicropubResponses)->insufficientScopeResponse(); + + 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 ($request->hasFile('file') === false) { @@ -98,10 +134,7 @@ class MicropubMediaController extends Controller ], 400); } - /** @var UploadedFile $file */ - $file = $request->file('file'); - - if ($file->isValid() === false) { + if ($request->file('file')->isValid() === false) { return response()->json([ 'response' => 'error', 'error' => 'invalid_request', @@ -109,25 +142,31 @@ class MicropubMediaController extends Controller ], 400); } - $filename = Storage::disk('local')->putFile('media', $file); + $filename = $this->saveFile($request->file('file')); - /** @var ImageManager $manager */ $manager = resolve(ImageManager::class); try { - $image = $manager->read($request->file('file')); + $image = $manager->make($request->file('file')); $width = $image->width(); - } catch (Exception) { + } catch (NotReadableException $exception) { // not an image $width = null; } $media = Media::create([ - 'token' => $request->input('access_token'), - 'path' => $filename, + 'token' => $request->bearerToken(), + 'path' => 'media/' . $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([ @@ -149,7 +188,7 @@ class MicropubMediaController extends Controller */ private function getFileTypeFromMimeType(string $mimeType): string { - // try known images + //try known images $imageMimeTypes = [ 'image/gif', 'image/jpeg', @@ -161,7 +200,7 @@ class MicropubMediaController extends Controller if (in_array($mimeType, $imageMimeTypes)) { return 'image'; } - // try known video + //try known video $videoMimeTypes = [ 'video/mp4', 'video/mpeg', @@ -172,7 +211,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', @@ -191,7 +230,7 @@ class MicropubMediaController extends Controller * * @throws Exception */ - private function saveFileToLocal(UploadedFile $file): string + private function saveFile(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 d5c9bc90..834ce907 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -14,6 +14,8 @@ use Jonnybarnes\IndieWeb\Numbers; /** * @todo Need to sort out Twitter and webmentions! + * + * @psalm-suppress UnusedClass */ class NotesController extends Controller { @@ -24,14 +26,8 @@ class NotesController extends Controller { $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')); @@ -43,16 +39,7 @@ class NotesController extends Controller public function show(string $urlId): View|JsonResponse|Response { try { - $note = Note::nb60($urlId)->with('place', 'media', 'client') - ->withCount(['webmentions AS replies' => function ($query) { - $query->where('type', 'in-reply-to'); - }]) - ->withCount(['webmentions AS likes' => function ($query) { - $query->where('type', 'like-of'); - }]) - ->withCount(['webmentions AS reposts' => function ($query) { - $query->where('type', 'repost-of'); - }])->firstOrFail(); + $note = Note::nb60($urlId)->with('webmentions')->firstOrFail(); } catch (ModelNotFoundException $exception) { abort(404); } @@ -65,7 +52,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/PlacesController.php b/app/Http/Controllers/PlacesController.php index b949ecde..b9bae93b 100644 --- a/app/Http/Controllers/PlacesController.php +++ b/app/Http/Controllers/PlacesController.php @@ -7,6 +7,9 @@ namespace App\Http\Controllers; use App\Models\Place; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class PlacesController extends Controller { /** diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 3f366538..42e9e00d 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -6,6 +6,9 @@ use App\Models\Note; use Illuminate\Http\Request; use Illuminate\View\View; +/** + * @psalm-suppress UnusedClass + */ class SearchController extends Controller { public function search(Request $request): View @@ -17,16 +20,7 @@ class SearchController extends Controller /** @var Note $note */ foreach ($notes as $note) { - $note->load('place', 'media', 'client') - ->loadCount(['webmentions AS replies' => function ($query) { - $query->where('type', 'in-reply-to'); - }]) - ->loadCount(['webmentions AS likes' => function ($query) { - $query->where('type', 'like-of'); - }]) - ->loadCount(['webmentions AS reposts' => function ($query) { - $query->where('type', 'repost-of'); - }]); + $note->load('place', 'media', 'client'); } return view('search', compact('search', 'notes')); diff --git a/app/Http/Controllers/ShortURLsController.php b/app/Http/Controllers/ShortURLsController.php new file mode 100644 index 00000000..a232fcdb --- /dev/null +++ b/app/Http/Controllers/ShortURLsController.php @@ -0,0 +1,55 @@ +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('url.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 49eac9b2..700a7e23 100644 --- a/app/Http/Controllers/WebMentionsController.php +++ b/app/Http/Controllers/WebMentionsController.php @@ -12,6 +12,9 @@ use Illuminate\Http\Response; use Illuminate\View\View; use Jonnybarnes\IndieWeb\Numbers; +/** + * @psalm-suppress UnusedClass + */ class WebMentionsController extends Controller { /** @@ -30,7 +33,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 +41,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 new file mode 100644 index 00000000..3557e09c --- /dev/null +++ b/app/Http/Kernel.php @@ -0,0 +1,74 @@ + + */ + 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\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\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 instead of class names 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, + 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + 'signed' => \App\Http\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/CSPHeader.php b/app/Http/Middleware/CSPHeader.php new file mode 100644 index 00000000..42b53ec2 --- /dev/null +++ b/app/Http/Middleware/CSPHeader.php @@ -0,0 +1,48 @@ +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/CorsHeaders.php b/app/Http/Middleware/CorsHeaders.php index cacf9188..eae6c51d 100644 --- a/app/Http/Middleware/CorsHeaders.php +++ b/app/Http/Middleware/CorsHeaders.php @@ -10,6 +10,8 @@ class CorsHeaders { /** * Handle an incoming request. + * + * @psalm-suppress PossiblyUnusedMethod */ public function handle(Request $request, Closure $next): Response { diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index 467283db..66896428 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -10,15 +10,16 @@ class LinkHeadersMiddleware { /** * Handle an incoming request. + * + * @psalm-suppress PossiblyUnusedMethod */ public function handle(Request $request, Closure $next): Response { $response = $next($request); - $response->header('Link', '<' . route('indieauth.metadata') . '>; rel="indieauth-metadata"', false); - $response->header('Link', '<' . route('indieauth.start') . '>; rel="authorization_endpoint"', false); - $response->header('Link', '<' . route('indieauth.token') . '>; rel="token_endpoint"', false); - $response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false); - $response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false); + $response->header('Link', '; rel="authorization_endpoint"', false); + $response->header('Link', '<' . config('app.url') . '/api/token>; rel="token_endpoint"', false); + $response->header('Link', '<' . config('app.url') . '/api/post>; rel="micropub"', false); + $response->header('Link', '<' . config('app.url') . '/webmention>; rel="webmention"', false); return $response; } diff --git a/app/Http/Middleware/LocalhostSessionMiddleware.php b/app/Http/Middleware/LocalhostSessionMiddleware.php index 060682d5..c7d8ac4c 100644 --- a/app/Http/Middleware/LocalhostSessionMiddleware.php +++ b/app/Http/Middleware/LocalhostSessionMiddleware.php @@ -14,6 +14,8 @@ class LocalhostSessionMiddleware * Whilst we are developing locally, automatically log in as * `['me' => config('app.url')]` as I can’t manually log in as * a .localhost domain. + * + * @psalm-suppress PossiblyUnusedMethod */ public function handle(Request $request, Closure $next): Response { diff --git a/app/Http/Middleware/LogMicropubRequest.php b/app/Http/Middleware/LogMicropubRequest.php deleted file mode 100644 index a04e80de..00000000 --- a/app/Http/Middleware/LogMicropubRequest.php +++ /dev/null @@ -1,24 +0,0 @@ -pushHandler(new StreamHandler(storage_path('logs/micropub.log'))); - $logger->debug('MicropubLog', $request->all()); - - return $next($request); - } -} diff --git a/app/Http/Middleware/MyAuthMiddleware.php b/app/Http/Middleware/MyAuthMiddleware.php index b22e2b33..26c8315f 100644 --- a/app/Http/Middleware/MyAuthMiddleware.php +++ b/app/Http/Middleware/MyAuthMiddleware.php @@ -13,13 +13,13 @@ class MyAuthMiddleware { /** * Check the user is logged in. + * + * @psalm-suppress PossiblyUnusedMethod */ public function handle(Request $request, Closure $next): Response { 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 index 093bf64a..2beb3c93 100644 --- a/app/Http/Middleware/ValidateSignature.php +++ b/app/Http/Middleware/ValidateSignature.php @@ -10,6 +10,8 @@ class ValidateSignature extends Middleware * The names of the query string parameters that should be ignored. * * @var array + * + * @psalm-suppress PossiblyUnusedProperty */ protected $except = [ // 'fbclid', diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index 33d2cb12..b68e999b 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -4,14 +4,8 @@ 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 @@ -19,63 +13,24 @@ class VerifyMicropubToken /** * Handle an incoming request. * - * @param Closure(Request): (Response) $next + * @psalm-suppress PossiblyUnusedMethod */ public function handle(Request $request, Closure $next): Response { - $rawToken = null; - if ($request->input('access_token')) { - $rawToken = $request->input('access_token'); - } elseif ($request->bearerToken()) { - $rawToken = $request->bearerToken(); + return $next($request); } - if (! $rawToken) { - return response()->json([ - 'response' => 'error', - 'error' => 'unauthorized', - 'error_description' => 'No access token was provided in the request', - ], 401); + if ($request->bearerToken()) { + return $next($request->merge([ + 'access_token' => $request->bearerToken(), + ])); } - try { - $tokenData = $this->validateToken($rawToken); - } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { - $micropubResponses = new MicropubResponses; - - return $micropubResponses->invalidTokenResponse(); - } - - if ($tokenData->claims()->has('scope') === false) { - $micropubResponses = new MicropubResponses; - - return $micropubResponses->tokenHasNoScopeResponse(); - } - - return $next($request->merge([ - 'access_token' => $rawToken, - 'token_data' => [ - 'me' => $tokenData->claims()->get('me'), - 'scope' => $tokenData->claims()->get('scope'), - 'client_id' => $tokenData->claims()->get('client_id'), - ], - ])); - } - - /** - * Check the token signature is valid. - */ - private function validateToken(string $bearerToken): Token - { - $config = resolve(Configuration::class); - - $token = $config->parser()->parse($bearerToken); - - $constraints = $config->validationConstraints(); - - $config->validator()->assert($token, ...$constraints); - - return $token; + return response()->json([ + 'response' => 'error', + 'error' => 'unauthorized', + 'error_description' => 'No access token was provided in the request', + ], 401); } } diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php deleted file mode 100644 index d931f139..00000000 --- a/app/Http/Requests/MicropubRequest.php +++ /dev/null @@ -1,106 +0,0 @@ -micropubData; - } - - public function getType(): ?string - { - // Return consistent type regardless of input format - return $this->micropubData['type'] ?? null; - } - - protected function prepareForValidation(): void - { - // Normalize the request data based on content type - if ($this->isJson()) { - $this->normalizeMicropubJson(); - } else { - $this->normalizeMicropubForm(); - } - } - - private function normalizeMicropubJson(): void - { - $json = $this->json(); - if ($json === null) { - throw new \InvalidArgumentException('`isJson()` passed but there is no json data'); - } - - $data = $json->all(); - - // Convert JSON type (h-entry) to simple type (entry) - if (isset($data['type']) && is_array($data['type'])) { - $type = current($data['type']); - if (strpos($type, 'h-') === 0) { - $this->micropubData['type'] = substr($type, 2); - } - } - // Or set the type to update - elseif (isset($data['action']) && $data['action'] === 'update') { - $this->micropubData['type'] = 'update'; - } - - // Add in the token data - $this->micropubData['token_data'] = $data['token_data']; - - // Add h-entry values - $this->micropubData['content'] = Arr::get($data, 'properties.content.0'); - $this->micropubData['in-reply-to'] = Arr::get($data, 'properties.in-reply-to.0'); - $this->micropubData['published'] = Arr::get($data, 'properties.published.0'); - $this->micropubData['location'] = Arr::get($data, 'location'); - $this->micropubData['bookmark-of'] = Arr::get($data, 'properties.bookmark-of.0'); - $this->micropubData['like-of'] = Arr::get($data, 'properties.like-of.0'); - $this->micropubData['mp-syndicate-to'] = Arr::get($data, 'properties.mp-syndicate-to'); - - // Add h-card values - $this->micropubData['name'] = Arr::get($data, 'properties.name.0'); - $this->micropubData['description'] = Arr::get($data, 'properties.description.0'); - $this->micropubData['geo'] = Arr::get($data, 'properties.geo.0'); - - // Add checkin value - $this->micropubData['checkin'] = Arr::get($data, 'checkin'); - $this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0'); - } - - private function normalizeMicropubForm(): void - { - // Convert form h=entry to type=entry - if ($h = $this->input('h')) { - $this->micropubData['type'] = $h; - } - - // Add some fields to the micropub data with default null values - $this->micropubData['in-reply-to'] = null; - $this->micropubData['published'] = null; - $this->micropubData['location'] = null; - $this->micropubData['description'] = null; - $this->micropubData['geo'] = null; - $this->micropubData['latitude'] = null; - $this->micropubData['longitude'] = null; - - // Map form fields to micropub data - foreach ($this->except(['h', 'access_token']) as $key => $value) { - $this->micropubData[$key] = $value; - } - } -} diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php index 3c187dd4..72f469e5 100644 --- a/app/Jobs/DownloadWebMention.php +++ b/app/Jobs/DownloadWebMention.php @@ -24,7 +24,8 @@ class DownloadWebMention implements ShouldQueue */ public function __construct( protected string $source - ) {} + ) { + } /** * Execute the job. @@ -35,30 +36,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 96f65e87..b1dffe8a 100644 --- a/app/Jobs/ProcessBookmark.php +++ b/app/Jobs/ProcessBookmark.php @@ -25,7 +25,8 @@ 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 3c6028a9..3a2d6f62 100644 --- a/app/Jobs/ProcessLike.php +++ b/app/Jobs/ProcessLike.php @@ -30,7 +30,8 @@ class ProcessLike implements ShouldQueue */ public function __construct( protected Like $like - ) {} + ) { + } /** * Execute the job. @@ -49,7 +50,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 b7f36648..f35bdd1a 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\Exceptions\DecoderException; +use Intervention\Image\Exception\NotReadableException; use Intervention\Image\ImageManager; class ProcessMedia implements ShouldQueue @@ -25,45 +25,43 @@ class ProcessMedia implements ShouldQueue */ public function __construct( protected string $filename - ) {} + ) { + } /** * Execute the job. */ public function handle(ImageManager $manager): void { - // Load file - $file = Storage::disk('local')->get('media/' . $this->filename); - - // Open file + //open file try { - $image = $manager->read($file); - } catch (DecoderException) { + $image = $manager->make(storage_path('app') . '/' . $this->filename); + } catch (NotReadableException $exception) { // not an image; delete file and end job - Storage::disk('local')->delete('media/' . $this->filename); + unlink(storage_path('app') . '/' . $this->filename); return; } - - // Save the file publicly - Storage::disk('public')->put('media/' . $this->filename, $file); - - // Create smaller versions if necessary + //create smaller versions if necessary if ($image->width() > 1000) { $filenameParts = explode('.', $this->filename); $extension = array_pop($filenameParts); // the following achieves this data flow // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar - $basename = trim(implode('.', $filenameParts), '.'); - - $medium = $image->resize(width: 1000); - Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode()); - - $small = $image->resize(width: 500); - Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); + $basename = ltrim(array_reduce($filenameParts, function ($carry, $item) { + return $carry . '.' . $item; + }, ''), '.'); + $medium = $image->resize(1000, null, function ($constraint) { + $constraint->aspectRatio(); + }); + Storage::disk('s3')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode()); + $small = $image->resize(500, null, function ($constraint) { + $constraint->aspectRatio(); + }); + Storage::disk('s3')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); } - // Now we can delete the locally saved image - Storage::disk('local')->delete('media/' . $this->filename); + // now we can delete the locally saved image + unlink(storage_path('app') . '/' . $this->filename); } } diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index d92dfa18..6aacf29d 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -30,7 +30,8 @@ class ProcessWebMention implements ShouldQueue public function __construct( protected Note $note, protected string $source - ) {} + ) { + } /** * Execute the job. @@ -44,7 +45,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); @@ -53,7 +54,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->uri) === false) { + if ($parser->checkInReplyTo($microformats, $this->note->longurl) === false) { // it doesn’t so delete $webmention->delete(); @@ -67,7 +68,7 @@ class ProcessWebMention implements ShouldQueue return; } if ($webmention->type === 'like-of') { - if ($parser->checkLikeOf($microformats, $this->note->uri) === false) { + if ($parser->checkLikeOf($microformats, $this->note->longurl) === false) { // it doesn’t so delete $webmention->delete(); @@ -75,7 +76,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->uri) === false) { + if ($parser->checkRepostOf($microformats, $this->note->longurl) === false) { // it doesn’t so delete $webmention->delete(); @@ -85,13 +86,13 @@ class ProcessWebMention implements ShouldQueue }// foreach // no webmention in the db so create new one - $webmention = new WebMention; + $webmention = new WebMention(); $type = $parser->getMentionType($microformats); // throw error here? dispatch(new SaveProfileImage($microformats)); $webmention->source = $this->source; - $webmention->target = $this->note->uri; + $webmention->target = $this->note->longurl; $webmention->commentable_id = $this->note->id; - $webmention->commentable_type = Note::class; + $webmention->commentable_type = 'App\Model\Note'; $webmention->type = $type; $webmention->mf2 = json_encode($microformats); $webmention->save(); diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php index 08152d5b..dd5422a9 100644 --- a/app/Jobs/SaveProfileImage.php +++ b/app/Jobs/SaveProfileImage.php @@ -25,7 +25,8 @@ class SaveProfileImage implements ShouldQueue */ public function __construct( protected array $microformats - ) {} + ) { + } /** * Execute the job. @@ -49,7 +50,7 @@ class SaveProfileImage implements ShouldQueue $home = array_shift($home); } - // dont save pbs.twimg.com links + //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 0e07efbd..c086276c 100755 --- a/app/Jobs/SaveScreenshot.php +++ b/app/Jobs/SaveScreenshot.php @@ -23,7 +23,8 @@ 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 2ff5f2c6..7e984537 100644 --- a/app/Jobs/SendWebMentions.php +++ b/app/Jobs/SendWebMentions.php @@ -27,7 +27,8 @@ class SendWebMentions implements ShouldQueue */ public function __construct( protected Note $note - ) {} + ) { + } /** * Execute the job. @@ -45,7 +46,7 @@ class SendWebMentions implements ShouldQueue $guzzle = resolve(Client::class); $guzzle->post($endpoint, [ 'form_params' => [ - 'source' => $this->note->uri, + 'source' => $this->note->longurl, 'target' => $url, ], ]); @@ -61,7 +62,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) === parse_url(config('app.url'), PHP_URL_HOST)) { + if (parse_url($url, PHP_URL_HOST) === config('url.longurl')) { return null; } if (Str::startsWith($url, '/notes/tagged/')) { @@ -72,15 +73,15 @@ class SendWebMentions implements ShouldQueue $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 (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { + if (mb_stristr($link['rel'], 'webmention')) { return $this->resolveUri(trim($link[0], '<>'), $url); } } - // failed to find a header so parse HTML + //failed to find a header so parse HTML $html = (string) $response->getBody(); $mf2 = new \Mf2\Parser($html, $url); @@ -108,7 +109,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 deleted file mode 100644 index e815be34..00000000 --- a/app/Jobs/SyndicateNoteToBluesky.php +++ /dev/null @@ -1,62 +0,0 @@ -request( - 'POST', - 'https://brid.gy/micropub', - [ - 'headers' => [ - 'Authorization' => 'Bearer ' . config('bridgy.bluesky_token'), - ], - 'json' => [ - 'type' => ['h-entry'], - 'properties' => [ - 'content' => [$this->note->getRawOriginal('note')], - ], - ], - ] - ); - - // Parse for syndication URL - if ($response->getStatusCode() === 201) { - $this->note->bluesky_url = $response->getHeader('Location')[0]; - $this->note->save(); - } - } -} diff --git a/app/Jobs/SyndicateNoteToMastodon.php b/app/Jobs/SyndicateNoteToMastodon.php index b79c092c..557006a4 100644 --- a/app/Jobs/SyndicateNoteToMastodon.php +++ b/app/Jobs/SyndicateNoteToMastodon.php @@ -22,7 +22,8 @@ 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 bfbd5d51..5b660076 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 37027e40..29bd25ad 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 local_uri(): Attribute + protected function longurl(): 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 3d923bed..c4dd6d5c 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -33,7 +33,7 @@ class Media extends Model return $attributes['path']; } - return config('app.url') . '/storage/' . $attributes['path']; + return config('filesystems.disks.s3.url') . '/' . $attributes['path']; } ); } @@ -78,7 +78,7 @@ class Media extends Model $basename = $this->getBasename($path); $extension = $this->getExtension($path); - return config('app.url') . '/storage/' . $basename . '-' . $size . '.' . $extension; + return config('filesystems.disks.s3.url') . '/' . $basename . '-' . $size . '.' . $extension; } private function getBasename(string $path): string diff --git a/app/Models/Note.php b/app/Models/Note.php index 74533443..99f2e193 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\MentionGenerator; -use App\CommonMark\Renderers\MentionRenderer; +use App\CommonMark\Generators\ContactMentionGenerator; +use App\CommonMark\Renderers\ContactMentionRenderer; 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->getRawOriginal('note'); + $note = $this->note; foreach ($this->media as $media) { if ($media->type === 'image') { - $note .= PHP_EOL . ''; + $note .= ''; } if ($media->type === 'audio') { - $note .= PHP_EOL . '