diff --git a/.editorconfig b/.editorconfig index 0b5d680f..5a999757 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,21 +1,18 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file root = true +# Unix-style newlines with a newline ending every file [*] -charset = utf-8 end_of_line = lf -indent_size = 4 -indent_style = space +charset = utf-8 insert_final_newline = true trim_trailing_whitespace = true - -[*.{js,css}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false - -[*.{yml,yaml}] -indent_size = 2 - -[docker-compose.yml] +indent_style = space indent_size = 4 + +# Tab indentation +[Makefile] +indent_style = tab +tab_width = 4 diff --git a/.env.example b/.env.example index ccd42db9..114a68a0 100644 --- a/.env.example +++ b/.env.example @@ -1,89 +1,68 @@ APP_NAME=Laravel -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=https://example.com -APP_LONGURL=example.com -APP_SHORTURL=examp.le +APP_ENV=production +APP_KEY=SomeRandomString # Leave this +APP_DEBUG=false +APP_LOG_LEVEL=warning -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US - -APP_MAINTENANCE_DRIVER=file -APP_MAINTENANCE_STORE=database - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=mysql +DB_CONNECTION=pgsql DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=laravel -DB_USERNAME=root +DB_PORT=5432 +DB_DATABASE= +DB_USERNAME= DB_PASSWORD= -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null +BROADCAST_DRIVER=log +CACHE_DRIVER=file +SESSION_DRIVER=file +QUEUE_DRIVER=sync -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log -MAIL_HOST=127.0.0.1 +MAIL_DRIVER=smtp +MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= -VITE_APP_NAME="${APP_NAME}" +AWS_S3_KEY=your-key +AWS_S3_SECRET=your-secret +AWS_S3_REGION=region +AWS_S3_BUCKET=your-bucket +AWS_S3_URL=https://xxxxxxx.s3-region.amazonaws.com -ADMIN_USER=admin# pick something better, this is used for `/admin` +APP_URL=https://example.com # This one is necessary +APP_LONGURL=example.com +APP_SHORTURL=examp.le + +ADMIN_USER=admin # pick something better, this is used for `/admin` ADMIN_PASS=password -DISPLAY_NAME='Joe Bloggs'# This is used for example in the header and titles +DISPLAY_NAME='Joe Bloggs' # This is used for example in the header and titles TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_SECRET= TWITTER_ACCESS_TOKEN= TWITTER_ACCESS_TOKEN_SECRET= -SCOUT_DRIVER=database -SCOUT_QUEUE=false +SCOUT_DRIVER=pgsql -SESSION_SECURE_COOKIE=true -SESSION_SAME_SITE=strict +PIWIK=false +PIWIK_ID=1 +PIWIK_URL=https://analytics.jmb.lv/piwik.php + +FATHOM_ID= + +APP_TIMEZONE=UTC +APP_LANG=en +APP_LOG=daily +SECURE_SESSION_COOKIE=true LOG_SLACK_WEBHOOK_URL= -FLARE_KEY= - -IGNITION_OPEN_AI_KEY= - -BRIDGY_MASTODON_TOKEN= +FONT_LINK= diff --git a/.env.github b/.env.github index 0ef2b89b..6ebe09fb 100644 --- a/.env.github +++ b/.env.github @@ -7,7 +7,7 @@ APP_LOG_LEVEL=warning DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 -DB_DATABASE=jbukdev_testing +DB_DATABASE=jbuktest DB_USERNAME=postgres DB_PASSWORD=postgres @@ -50,8 +50,7 @@ TWITTER_CONSUMER_SECRET= TWITTER_ACCESS_TOKEN= TWITTER_ACCESS_TOKEN_SECRET= -SCOUT_DRIVER=database -SCOUT_QUEUE=false +SCOUT_DRIVER=pgsql PIWIK=false @@ -63,8 +62,5 @@ APP_LOG=daily SECURE_SESSION_COOKIE=true LOG_SLACK_WEBHOOK_URL= -FLARE_KEY= FONT_LINK= - -BRIDGY_MASTODON_TOKEN= diff --git a/.env.travis b/.env.travis new file mode 100644 index 00000000..3b70d5d2 --- /dev/null +++ b/.env.travis @@ -0,0 +1,17 @@ +APP_ENV=testing +APP_DEBUG=true +APP_KEY=base64:6DJhvZLVjE6dD4Cqrteh+6Z5vZlG+v/soCKcDHLOAH0= +APP_URL=http://jonnybarnes.localhost +APP_LONGURL=jonnybarnes.localhost +APP_SHORTURL=jmb.localhost + +DB_CONNECTION=travis + +CACHE_DRIVER=array +SESSION_DRIVER=array +QUEUE_DRIVER=sync + +SCOUT_DRIVER=pgsql + +DISPLAY_NAME='Travis Test' +USER_NAME=travis diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..b6ca2fd4 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,24 @@ +parserOptions: + sourceType: 'module' +extends: 'eslint:recommended' +env: + browser: true + es6: true +rules: + indent: + - error + - 4 + linebreak-style: + - error + - unix + quotes: + - error + - single + semi: + - error + - always + no-console: + - error + - allow: + - warn + - error diff --git a/.gitattributes b/.gitattributes index fcb21d39..967315dd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,5 @@ -* text=auto eol=lf - -*.blade.php diff=html -*.css diff=css -*.html diff=html -*.md diff=markdown -*.php diff=php - -/.github export-ignore +* text=auto +*.css linguist-vendored +*.scss linguist-vendored +*.js linguist-vendored CHANGELOG.md export-ignore -.styleci.yml export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 3ebccbd3..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: "composer" - directory: "/" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index f66a77b4..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 29afebb9..00000000 --- a/.github/workflows/phpunit.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: PHP Unit - -on: - pull_request: - -jobs: - phpunit: - runs-on: ubuntu-latest - - name: PHPUnit test suite - - services: - postgres: - image: postgres:latest - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: jbukdev_testing - ports: - - 5432:5432 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - extensions: mbstring, intl, phpredis, imagick - coverage: xdebug - tools: phpunit - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - 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@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-php-8.3-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php-8.3-composer- - - - name: Install Composer Dependencies - run: composer install --quiet --no-ansi --no-interaction --no-progress - - - name: Generate Key - run: php artisan key:generate - - - name: Setup Directory Permissions - run: chmod -R 777 storage bootstrap/cache - - - name: Setup Database - run: php artisan migrate - - - name: Execute PHPUnit Tests - run: vendor/bin/phpunit diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml deleted file mode 100644 index 9b0956ad..00000000 --- a/.github/workflows/pint.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Laravel Pint - -on: - pull_request: - -jobs: - pint: - runs-on: ubuntu-latest - - name: Laravel Pint - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - 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@v4 - 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/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..8bb94848 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,56 @@ +name: Run Tests + +on: + pull_request: + +jobs: + phpunit: + runs-on: ubuntu-20.04 + + name: PHPUnit test suite + + services: + postgres: + image: postgres:12 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: jbuktest + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v2 + - name: Cache node modules + uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-${{ hashFiles('**/package.json') }} + - name: Install npm dependencies + run: npm install + - name: Install ImageMagick + run: sudo apt install imagemagick + - name: Setup PHP with pecl extension + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + tools: pecl, phpcs + extensions: imagick + - name: Copy .env + run: php -r "file_exists('.env') || copy('.env.github', '.env');" + - name: Install dependencies + run: composer install -q --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 test database + run: | + php artisan migrate + php artisan db:seed + - name: Execute tests (Unit and Feature tests) via PHPUnit + run: vendor/bin/phpunit + - name: Run phpcs + run: phpcs + - name: Check for security vulnerabilities + run: php vendor/bin/security-checker security:check diff --git a/.gitignore b/.gitignore index 5a9b11c9..bef5efd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,20 @@ -/.phpunit.cache /node_modules -/public/build -/public/coverage -/public/hot -/public/files -/public/fonts -/public/storage /storage/*.key /vendor .env -.env.backup -.env.production .phpunit.result.cache Homestead.json Homestead.yaml -auth.json npm-debug.log yarn-error.log -/.fleet /.idea -/.vscode -ray.php +/lsp +.phpstorm.meta.php +_ide_helper.php +# Custom paths in /public +/public/coverage +/public/hot +/public/storage +/public/fonts +/public/files +/public/keybase.txt diff --git a/.styleci.yml b/.styleci.yml index 9daadf16..0fb4a09b 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,9 +1,8 @@ -php: - preset: laravel - disabled: - - no_unused_imports - finder: - not-name: - - index.php -js: true -css: true +preset: laravel + +disabled: + - concat_without_spaces + - single_import_per_statement + +finder: + path: app/ diff --git a/.stylelintrc b/.stylelintrc index a9a9091b..a8ec8a82 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,3 +1,6 @@ { - "extends": ["stylelint-config-standard"] + "extends": ["stylelint-config-standard", "stylelint-a11y/recommended"], + "rules": { + "indentation": 4 + } } diff --git a/app/CommonMark/Generators/MentionGenerator.php b/app/CommonMark/Generators/MentionGenerator.php deleted file mode 100644 index 2ac1a797..00000000 --- a/app/CommonMark/Generators/MentionGenerator.php +++ /dev/null @@ -1,17 +0,0 @@ -getIdentifier())->first(); - - // If we have a contact, render a mini-hcard - if ($contact) { - // rendering a blade template to a string, so can’t be an HtmlElement - return trim(view('templates.mini-hcard', ['contact' => $contact])->render()); - } - - // Otherwise, check the link is to the Mastodon profile - $mentionText = $node->getIdentifier(); - $parts = explode('@', $mentionText); - - // This is not [@]handle@instance, so return a Twitter link - if (count($parts) === 1) { - return new HtmlElement('a', ['href' => 'https://twitter.com/' . $parts[0]], '@' . $mentionText); - } - - // Render the Mastodon profile link - return new HtmlElement('a', ['href' => 'https://' . $parts[1] . '/@' . $parts[0]], '@' . $mentionText); - } -} diff --git a/app/Console/Commands/MigratePlaceDataFromPostgis.php b/app/Console/Commands/MigratePlaceDataFromPostgis.php index e0026150..a4e0f38a 100644 --- a/app/Console/Commands/MigratePlaceDataFromPostgis.php +++ b/app/Console/Commands/MigratePlaceDataFromPostgis.php @@ -6,11 +6,6 @@ use App\Models\Place; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -/** - * @codeCoverageIgnore - * - * @psalm-suppress UnusedClass - */ class MigratePlaceDataFromPostgis extends Command { /** @@ -28,9 +23,21 @@ class MigratePlaceDataFromPostgis extends Command protected $description = 'Copy Postgis data to normal latitude longitude fields'; /** - * Execute the console command. + * Create a new command instance. + * + * @return void */ - public function handle(): int + public function __construct() + { + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() { $locationColumn = DB::selectOne(DB::raw(" SELECT EXISTS ( diff --git a/app/Console/Commands/ParseCachedWebMentions.php b/app/Console/Commands/ParseCachedWebMentions.php index 96d57332..2183cd4a 100644 --- a/app/Console/Commands/ParseCachedWebMentions.php +++ b/app/Console/Commands/ParseCachedWebMentions.php @@ -6,12 +6,8 @@ namespace App\Console\Commands; use App\Models\WebMention; use Illuminate\Console\Command; -use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\FileSystem\FileSystem; -/** - * @psalm-suppress UnusedClass - */ class ParseCachedWebMentions extends Command { /** @@ -28,22 +24,32 @@ class ParseCachedWebMentions extends Command */ protected $description = 'Re-parse the webmention’s cached HTML'; + /** + * Create a new command instance. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + } + /** * Execute the console command. * - * @throws FileNotFoundException + * @return mixed */ - public function handle(FileSystem $filesystem): void + public function handle(FileSystem $filesystem) { - $htmlFiles = $filesystem->allFiles(storage_path() . '/HTML'); - foreach ($htmlFiles as $file) { - if ($file->getExtension() !== 'backup') { //we don’t want to parse `.backup` files + $HTMLfiles = $filesystem->allFiles(storage_path() . '/HTML'); + foreach ($HTMLfiles as $file) { + if ($file->getExtension() != 'backup') { //we don’t want to parse.backup files $filepath = $file->getPathname(); $this->info('Loading HTML from: ' . $filepath); $html = $filesystem->get($filepath); - $url = $this->urlFromFilename($filepath); - $webmention = WebMention::where('source', $url)->firstOrFail(); + $url = $this->URLFromFilename($filepath); $microformats = \Mf2\parse($html, $url); + $webmention = WebMention::where('source', $url)->firstOrFail(); $webmention->mf2 = json_encode($microformats); $webmention->save(); $this->info('Saved the microformats to the database.'); @@ -53,13 +59,16 @@ class ParseCachedWebMentions extends Command /** * Determine the source URL from a filename. + * + * @param string + * @return string */ - private function urlFromFilename(string $filepath): string + private function URLFromFilename(string $filepath): string { $dir = mb_substr($filepath, mb_strlen(storage_path() . '/HTML/')); $url = str_replace(['http/', 'https/'], ['http://', 'https://'], $dir); - if (mb_substr($url, -10) === 'index.html') { - $url = mb_substr($url, 0, -10); + if (mb_substr($url, -10) == 'index.html') { + $url = mb_substr($url, 0, mb_strlen($url) - 10); } return $url; diff --git a/app/Console/Commands/ReDownloadWebMentions.php b/app/Console/Commands/ReDownloadWebMentions.php index b29e7da8..2c5c18e0 100644 --- a/app/Console/Commands/ReDownloadWebMentions.php +++ b/app/Console/Commands/ReDownloadWebMentions.php @@ -8,9 +8,6 @@ use App\Jobs\DownloadWebMention; use App\Models\WebMention; use Illuminate\Console\Command; -/** - * @psalm-suppress UnusedClass - */ class ReDownloadWebMentions extends Command { /** @@ -28,9 +25,21 @@ class ReDownloadWebMentions extends Command protected $description = 'Redownload the HTML content of webmentions'; /** - * Execute the console command. + * Create a new command instance. + * + * @return void */ - public function handle(): void + public function __construct() + { + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() { $webmentions = WebMention::all(); foreach ($webmentions as $webmention) { diff --git a/app/Console/Commands/SecurityCheck.php b/app/Console/Commands/SecurityCheck.php new file mode 100644 index 00000000..acd014ab --- /dev/null +++ b/app/Console/Commands/SecurityCheck.php @@ -0,0 +1,66 @@ +securityChecker = $securityChecker; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle(): int + { + $alerts = $this->securityChecker->check(base_path() . '/composer.lock'); + if (count($alerts) === 0) { + $this->info('No security vulnerabilities found.'); + + return 0; + } + $this->error('vulnerabilities found'); + + return 1; + } +} diff --git a/app/Console/Commands/UpdateWebmentionsRelationship.php b/app/Console/Commands/UpdateWebmentionsRelationship.php deleted file mode 100644 index f5bc1114..00000000 --- a/app/Console/Commands/UpdateWebmentionsRelationship.php +++ /dev/null @@ -1,36 +0,0 @@ -where('commentable_type', '=', 'App\Model\Note') - ->update(['commentable_type' => Note::class]); - - $this->info('All webmentions updated to relate to the correct note model class'); - } -} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 432844ad..551ef20a 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,20 +8,36 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel; class Kernel extends ConsoleKernel { /** - * Define the application's command schedule. + * The Artisan commands provided by your application. + * + * @var array */ - protected function schedule(Schedule $schedule): void + protected $commands = [ + Commands\SecurityCheck::class, + Commands\ParseCachedWebMentions::class, + Commands\ReDownloadWebMentions::class, + ]; + + /** + * Define the application's command schedule. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @return void + */ + protected function schedule(Schedule $schedule) { $schedule->command('horizon:snapshot')->everyFiveMinutes(); - $schedule->command('cache:prune-stale-tags')->hourly(); + $schedule->command('telescope:prune --hours=48')->daily(); } /** * Register the commands for the application. + * + * @return void */ - protected function commands(): void + protected function commands() { - $this->load(__DIR__.'/Commands'); + $this->load(__DIR__ . '/Commands'); require base_path('routes/console.php'); } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index cb48444a..2f7e5fbc 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,18 +2,102 @@ namespace App\Exceptions; +use Exception; +use GuzzleHttp\Client; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Session\TokenMismatchException; +use Illuminate\Support\Facades\Route; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Throwable; +/** + * @codeCoverageIgnore + */ class Handler extends ExceptionHandler { /** - * Register the exception handling callbacks for the application. + * A list of the exception types that are not reported. + * + * @var array */ - public function register(): void + protected $dontReport = [ + NotFoundHttpException::class, + ModelNotFoundException::class, + ]; + + /** + * A list of the inputs that are never flashed for validation exceptions. + * + * @var array + */ + protected $dontFlash = [ + 'password', + 'password_confirmation', + ]; + + /** + * Report or log an exception. + * + * This is a great spot to send exceptions to Sentry, Bugsnag, etc. + * + * @param Throwable $throwable + * @return void + * @throws Exception + * @throws Throwable + */ + public function report(Throwable $throwable) { - $this->reportable(function (Throwable $_e) { - // - }); + parent::report($throwable); + + if ($this->shouldReport($throwable)) { + $guzzle = new Client([ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ]); + + $exceptionName = get_class($throwable) ?? 'Unknown Exception'; + $title = $exceptionName . ': ' . $throwable->getMessage(); + + $guzzle->post( + config('logging.slack'), + [ + 'body' => json_encode([ + 'attachments' => [[ + 'fallback' => 'There was an exception.', + 'pretext' => 'There was an exception.', + 'color' => '#d00000', + 'author_name' => app()->environment(), + 'author_link' => config('app.url'), + 'fields' => [[ + 'title' => $title, + 'value' => request()->method() . ' ' . request()->fullUrl(), + ]], + 'ts' => time(), + ]], + ]), + ] + ); + } + } + + /** + * Render an exception into an HTTP response. + * + * @param Request $request + * @param Throwable $throwable + * @return Response + * @throws Throwable + */ + public function render($request, Throwable $throwable) + { + if ($throwable instanceof TokenMismatchException) { + Route::getRoutes()->match($request); + } + + return parent::render($request, $throwable); } } diff --git a/app/Exceptions/InternetArchiveException.php b/app/Exceptions/InternetArchiveException.php index 99d5cab7..7e810fea 100644 --- a/app/Exceptions/InternetArchiveException.php +++ b/app/Exceptions/InternetArchiveException.php @@ -2,4 +2,6 @@ namespace App\Exceptions; -class InternetArchiveException extends \Exception {} +class InternetArchiveException extends \Exception +{ +} diff --git a/app/Exceptions/InvalidTokenException.php b/app/Exceptions/InvalidTokenException.php new file mode 100644 index 00000000..8184cfa7 --- /dev/null +++ b/app/Exceptions/InvalidTokenException.php @@ -0,0 +1,13 @@ +orderBy('id', 'desc')->get(); @@ -21,6 +24,11 @@ class ArticlesController extends Controller return view('admin.articles.index', ['posts' => $posts]); } + /** + * Show the new article form. + * + * @return \Illuminate\View\View + */ public function create(): View { $message = session('message'); @@ -28,6 +36,11 @@ class ArticlesController extends Controller return view('admin.articles.create', ['message' => $message]); } + /** + * Process an incoming request for a new article and save it. + * + * @return \Illuminate\Http\RedirectResponse + */ public function store(): RedirectResponse { //if a `.md` is attached use that for the main content. @@ -36,21 +49,42 @@ class ArticlesController extends Controller $content = $file->fread($file->getSize()); } $main = $content ?? request()->input('main'); - Article::create([ - 'url' => request()->input('url'), - 'title' => request()->input('title'), - 'main' => $main, - 'published' => request()->input('published') ?? 0, - ]); + $article = Article::create( + [ + 'url' => request()->input('url'), + 'title' => request()->input('title'), + 'main' => $main, + 'published' => request()->input('published') ?? 0, + ] + ); return redirect('/admin/blog'); } - public function edit(Article $article): View + /** + * Show the edit form for an existing article. + * + * @param int $articleId + * @return \Illuminate\View\View + */ + public function edit(int $articleId): View { - return view('admin.articles.edit', ['article' => $article]); + $post = Article::select( + 'title', + 'main', + 'url', + 'published' + )->where('id', $articleId)->get(); + + return view('admin.articles.edit', ['id' => $articleId, 'post' => $post]); } + /** + * Process an incoming request to edit an article. + * + * @param int $articleId + * @return \Illuminate\Http\RedirectResponse + */ public function update(int $articleId): RedirectResponse { $article = Article::find($articleId); @@ -63,6 +97,12 @@ class ArticlesController extends Controller return redirect('/admin/blog'); } + /** + * Process a request to delete an aricle. + * + * @param int $articleId + * @return \Illuminate\Http\RedirectResponse + */ public function destroy(int $articleId): RedirectResponse { Article::where('id', $articleId)->delete(); diff --git a/app/Http/Controllers/Admin/BioController.php b/app/Http/Controllers/Admin/BioController.php deleted file mode 100644 index 8560eba9..00000000 --- a/app/Http/Controllers/Admin/BioController.php +++ /dev/null @@ -1,35 +0,0 @@ - $bio, - ]); - } - - public function update(Request $request): RedirectResponse - { - $bio = Bio::firstOrNew(); - $bio->content = $request->input('content'); - $bio->save(); - - return redirect()->route('admin.bio.show'); - } -} diff --git a/app/Http/Controllers/Admin/ClientsController.php b/app/Http/Controllers/Admin/ClientsController.php index 290da502..fd12cba9 100644 --- a/app/Http/Controllers/Admin/ClientsController.php +++ b/app/Http/Controllers/Admin/ClientsController.php @@ -7,15 +7,15 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\MicropubClient; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class ClientsController extends Controller { /** * Show a list of known clients. + * + * @return \Illuminate\View\View */ public function index(): View { @@ -26,6 +26,8 @@ class ClientsController extends Controller /** * Show form to add a client name. + * + * @return \Illuminate\View\View */ public function create(): View { @@ -34,6 +36,8 @@ class ClientsController extends Controller /** * Process the request to adda new client name. + * + * @return \Illuminate\Http\RedirectResponse */ public function store(): RedirectResponse { @@ -47,6 +51,9 @@ class ClientsController extends Controller /** * Show a form to edit a client name. + * + * @param int $clientId + * @return \Illuminate\View\View */ public function edit(int $clientId): View { @@ -61,6 +68,9 @@ class ClientsController extends Controller /** * Process the request to edit a client name. + * + * @param int $clientId + * @return \Illuminate\Http\RedirectResponse */ public function update(int $clientId): RedirectResponse { @@ -74,6 +84,9 @@ class ClientsController extends Controller /** * Process a request to delete a client. + * + * @param int $clientId + * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $clientId): RedirectResponse { diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php index 836c99cc..030ef797 100644 --- a/app/Http/Controllers/Admin/ContactsController.php +++ b/app/Http/Controllers/Admin/ContactsController.php @@ -9,16 +9,16 @@ use App\Models\Contact; use GuzzleHttp\Client; use Illuminate\Filesystem\Filesystem; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class ContactsController extends Controller { /** * List the currect contacts that can be edited. + * + * @return \Illuminate\View\View */ public function index(): View { @@ -29,6 +29,8 @@ class ContactsController extends Controller /** * Display the form to add a new contact. + * + * @return \Illuminate\View\View */ public function create(): View { @@ -37,6 +39,8 @@ class ContactsController extends Controller /** * Process the request to add a new contact. + * + * @return \Illuminate\Http\RedirectResponse */ public function store(): RedirectResponse { @@ -53,6 +57,9 @@ class ContactsController extends Controller /** * Show the form to edit an existing contact. + * + * @param int $contactId + * @return \Illuminate\View\View */ public function edit(int $contactId): View { @@ -65,6 +72,9 @@ class ContactsController extends Controller * Process the request to edit a contact. * * @todo Allow saving profile pictures for people without homepages + * + * @param int $contactId + * @return \Illuminate\Http\RedirectResponse */ public function update(int $contactId): RedirectResponse { @@ -91,6 +101,9 @@ class ContactsController extends Controller /** * Process the request to delete a contact. + * + * @param int $contactId + * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $contactId): RedirectResponse { @@ -106,6 +119,7 @@ class ContactsController extends Controller * This method attempts to find the microformat marked-up profile image * from a given homepage and save it accordingly * + * @param int $contactId * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View */ public function getAvatar(int $contactId) @@ -124,8 +138,8 @@ class ContactsController extends Controller } $mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage); foreach ($mf2['items'] as $microformat) { - if (Arr::get($microformat, 'type.0') === 'h-card') { - $avatarURL = Arr::get($microformat, 'properties.photo.0.value'); + if (Arr::get($microformat, 'type.0') == 'h-card') { + $avatarURL = Arr::get($microformat, 'properties.photo.0'); break; } } diff --git a/app/Http/Controllers/Admin/HomeController.php b/app/Http/Controllers/Admin/HomeController.php index d469c66c..ee10e4dc 100644 --- a/app/Http/Controllers/Admin/HomeController.php +++ b/app/Http/Controllers/Admin/HomeController.php @@ -7,13 +7,12 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class HomeController extends Controller { /** * Show the homepage of the admin CP. + * + * @return \Illuminate\View\View */ public function welcome(): View { diff --git a/app/Http/Controllers/Admin/LikesController.php b/app/Http/Controllers/Admin/LikesController.php index c8553348..102c281e 100644 --- a/app/Http/Controllers/Admin/LikesController.php +++ b/app/Http/Controllers/Admin/LikesController.php @@ -10,13 +10,12 @@ use App\Models\Like; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class LikesController extends Controller { /** * List the likes that can be edited. + * + * @return \Illuminate\View\View */ public function index(): View { @@ -27,6 +26,8 @@ class LikesController extends Controller /** * Show the form to make a new like. + * + * @return \Illuminate\View\View */ public function create(): View { @@ -35,6 +36,8 @@ class LikesController extends Controller /** * Process a request to make a new like. + * + * @return \Illuminate\Http\RedirectResponse */ public function store(): RedirectResponse { @@ -48,6 +51,9 @@ class LikesController extends Controller /** * Display the form to edit a specific like. + * + * @param int $likeId + * @return \Illuminate\View\View */ public function edit(int $likeId): View { @@ -61,6 +67,9 @@ class LikesController extends Controller /** * Process a request to edit a like. + * + * @param int $likeId + * @return \Illuminate\Http\RedirectResponse */ public function update(int $likeId): RedirectResponse { @@ -74,6 +83,9 @@ class LikesController extends Controller /** * Process the request to delete a like. + * + * @param int $likeId + * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $likeId): RedirectResponse { diff --git a/app/Http/Controllers/Admin/NotesController.php b/app/Http/Controllers/Admin/NotesController.php index afa75adb..75a15231 100644 --- a/app/Http/Controllers/Admin/NotesController.php +++ b/app/Http/Controllers/Admin/NotesController.php @@ -11,13 +11,12 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class NotesController extends Controller { /** * List the notes that can be edited. + * + * @return \Illuminate\View\View */ public function index(): View { @@ -31,6 +30,8 @@ class NotesController extends Controller /** * Show the form to make a new note. + * + * @return \Illuminate\View\View */ public function create(): View { @@ -39,12 +40,14 @@ class NotesController extends Controller /** * Process a request to make a new note. + * + * @return \Illuminate\Http\RedirectResponse */ - public function store(Request $request): RedirectResponse + public function store(): RedirectResponse { Note::create([ - 'in_reply_to' => $request->input('in-reply-to'), - 'note' => $request->input('content'), + 'in-reply-to' => request()->input('in-reply-to'), + 'note' => request()->input('content'), ]); return redirect('/admin/notes'); @@ -52,6 +55,9 @@ class NotesController extends Controller /** * Display the form to edit a specific note. + * + * @param int $noteId + * @return \Illuminate\View\View */ public function edit(int $noteId): View { @@ -64,6 +70,9 @@ class NotesController extends Controller /** * Process a request to edit a note. Easy since this can only be done * from the admin CP. + * + * @param int $noteId + * @return \Illuminate\Http\RedirectResponse */ public function update(int $noteId): RedirectResponse { @@ -82,6 +91,9 @@ class NotesController extends Controller /** * Delete the note. + * + * @param int $noteId + * @return \Illuminate\Http\RedirectResponse */ public function destroy(int $noteId): RedirectResponse { diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php deleted file mode 100644 index 49ca481b..00000000 --- a/app/Http/Controllers/Admin/PasskeysController.php +++ /dev/null @@ -1,299 +0,0 @@ -user(); - $passkeys = $user->passkey; - - return view('admin.passkeys.index', compact('passkeys')); - } - - public function getCreateOptions(): JsonResponse - { - /** @var User $user */ - $user = auth()->user(); - - // RP Entity i.e. the application - $rpEntity = PublicKeyCredentialRpEntity::create( - config('app.name'), - config('url.longurl'), - ); - - // User Entity - $userEntity = PublicKeyCredentialUserEntity::create( - $user->name, - (string) $user->id, - $user->name, - ); - - // Challenge - $challenge = random_bytes(16); - - // List of supported public key parameters - $pubKeyCredParams = collect([ - Algorithms::COSE_ALGORITHM_EDDSA, - Algorithms::COSE_ALGORITHM_ES256, - Algorithms::COSE_ALGORITHM_RS256, - ])->map( - fn ($algorithm) => PublicKeyCredentialParameters::create('public-key', $algorithm) - )->toArray(); - - $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create( - userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED, - residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED, - requireResidentKey: true, - ); - - $options = PublicKeyCredentialCreationOptions::create( - $rpEntity, - $userEntity, - $challenge, - $pubKeyCredParams, - authenticatorSelection: $authenticatorSelectionCriteria, - attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE - ); - - $options = json_encode($options, JSON_THROW_ON_ERROR); - - session(['create_options' => $options]); - - return JsonResponse::fromJsonString($options); - } - - public function create(Request $request): JsonResponse - { - /** @var User $user */ - $user = auth()->user(); - - $publicKeyCredentialCreationOptionsData = session('create_options'); - // Unset session data to mitigate replay attacks - session()->forget('create_options'); - if (empty($publicKeyCredentialCreationOptionsData)) { - throw new WebAuthnException('No public key credential request options found'); - } - - $attestationStatementSupportManager = new AttestationStatementSupportManager(); - $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); - - $webauthnSerializer = (new WebauthnSerializerFactory( - $attestationStatementSupportManager - ))->create(); - - $publicKeyCredential = $webauthnSerializer->deserialize( - json_encode($request->all(), JSON_THROW_ON_ERROR), - PublicKeyCredential::class, - 'json' - ); - - if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) { - throw new WebAuthnException('Invalid response type'); - } - - $algorithmManager = new Manager(); - $algorithmManager->add(new Ed25519()); - $algorithmManager->add(new ES256()); - $algorithmManager->add(new RS256()); - - $ceremonyStepManagerFactory = new CeremonyStepManagerFactory(); - $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); - $ceremonyStepManagerFactory->setAttestationStatementSupportManager( - $attestationStatementSupportManager - ); - $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( - ExtensionOutputCheckerHandler::create() - ); - $securedRelyingPartyId = []; - if (App::environment('local', 'development')) { - $securedRelyingPartyId = [config('url.longurl')]; - } - $ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId); - - $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( - ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony() - ); - - $publicKeyCredentialCreationOptions = $webauthnSerializer->deserialize( - $publicKeyCredentialCreationOptionsData, - PublicKeyCredentialCreationOptions::class, - 'json' - ); - - $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( - authenticatorAttestationResponse: $publicKeyCredential->response, - publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions, - request: config('url.longurl'), - securedRelyingPartyId: $securedRelyingPartyId, - ); - - $user->passkey()->create([ - 'passkey_id' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->publicKeyCredentialId), - 'passkey' => json_encode($publicKeyCredentialSource, JSON_THROW_ON_ERROR), - ]); - - return response()->json([ - 'success' => true, - 'message' => 'Passkey created successfully', - ]); - } - - public function getRequestOptions(): JsonResponse - { - $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create( - challenge: random_bytes(16), - userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED - ); - - $publicKeyCredentialRequestOptions = json_encode($publicKeyCredentialRequestOptions, JSON_THROW_ON_ERROR); - - session(['request_options' => $publicKeyCredentialRequestOptions]); - - return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions); - } - - public function login(Request $request): JsonResponse - { - $requestOptions = session('request_options'); - session()->forget('request_options'); - - if (empty($requestOptions)) { - return response()->json([ - 'success' => false, - 'message' => 'No request options found', - ], 400); - } - - $attestationStatementSupportManager = new AttestationStatementSupportManager(); - $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); - - $webauthnSerializer = (new WebauthnSerializerFactory( - $attestationStatementSupportManager - ))->create(); - - $publicKeyCredential = $webauthnSerializer->deserialize( - json_encode($request->all(), JSON_THROW_ON_ERROR), - PublicKeyCredential::class, - 'json' - ); - - if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) { - return response()->json([ - 'success' => false, - 'message' => 'Invalid response type', - ], 400); - } - - $passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id); - if (! $passkey) { - return response()->json([ - 'success' => false, - 'message' => 'Passkey not found', - ], 404); - } - - $publicKeyCredentialSource = $webauthnSerializer->deserialize( - $passkey->passkey, - PublicKeyCredentialSource::class, - 'json' - ); - - $algorithmManager = new Manager(); - $algorithmManager->add(new Ed25519()); - $algorithmManager->add(new ES256()); - $algorithmManager->add(new RS256()); - - $attestationStatementSupportManager = new AttestationStatementSupportManager(); - $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); - - $ceremonyStepManagerFactory = new CeremonyStepManagerFactory(); - $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); - $ceremonyStepManagerFactory->setAttestationStatementSupportManager( - $attestationStatementSupportManager - ); - $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( - ExtensionOutputCheckerHandler::create() - ); - $securedRelyingPartyId = []; - if (App::environment('local', 'development')) { - $securedRelyingPartyId = [config('url.longurl')]; - } - $ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId); - - $authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create( - ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony() - ); - - $publicKeyCredentialRequestOptions = $webauthnSerializer->deserialize( - $requestOptions, - PublicKeyCredentialRequestOptions::class, - 'json' - ); - - try { - $authenticatorAssertionResponseValidator->check( - credentialId: $publicKeyCredentialSource, - authenticatorAssertionResponse: $publicKeyCredential->response, - publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions, - request: config('url.longurl'), - userHandle: null, - securedRelyingPartyId: $securedRelyingPartyId, - ); - } catch (Throwable) { - return response()->json([ - 'success' => false, - 'message' => 'Passkey could not be verified', - ], 500); - } - - $user = User::find($passkey->user_id); - Auth::login($user); - - return response()->json([ - 'success' => true, - 'message' => 'Passkey verified successfully', - ]); - } -} diff --git a/app/Http/Controllers/Admin/PlacesController.php b/app/Http/Controllers/Admin/PlacesController.php index 2b0d2e99..e1025e2f 100644 --- a/app/Http/Controllers/Admin/PlacesController.php +++ b/app/Http/Controllers/Admin/PlacesController.php @@ -10,9 +10,6 @@ use App\Services\PlaceService; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class PlacesController extends Controller { protected PlaceService $placeService; @@ -24,6 +21,8 @@ class PlacesController extends Controller /** * List the places that can be edited. + * + * @return View */ public function index(): View { @@ -34,6 +33,8 @@ class PlacesController extends Controller /** * Show the form to make a new place. + * + * @return View */ public function create(): View { @@ -42,6 +43,8 @@ class PlacesController extends Controller /** * Process a request to make a new place. + * + * @return RedirectResponse */ public function store(): RedirectResponse { @@ -59,6 +62,9 @@ class PlacesController extends Controller /** * Display the form to edit a specific place. + * + * @param int $placeId + * @return View */ public function edit(int $placeId): View { @@ -69,6 +75,9 @@ class PlacesController extends Controller /** * Process a request to edit a place. + * + * @param int $placeId + * @return RedirectResponse */ public function update(int $placeId): RedirectResponse { @@ -85,6 +94,9 @@ class PlacesController extends Controller /** * List the places we can merge with the current place. + * + * @param int $placeId + * @return View */ public function mergeIndex(int $placeId): View { @@ -102,6 +114,10 @@ class PlacesController extends Controller /** * Show a form for merging two specific places. + * + * @param int $placeId1 + * @param int $placeId2 + * @return View */ public function mergeEdit(int $placeId1, int $placeId2): View { @@ -113,6 +129,8 @@ class PlacesController extends Controller /** * Process the request to merge two places. + * + * @return RedirectResponse */ public function mergeStore(): RedirectResponse { diff --git a/app/Http/Controllers/Admin/SyndicationTargetsController.php b/app/Http/Controllers/Admin/SyndicationTargetsController.php deleted file mode 100644 index 6eb60f69..00000000 --- a/app/Http/Controllers/Admin/SyndicationTargetsController.php +++ /dev/null @@ -1,97 +0,0 @@ -validate([ - 'uid' => 'required|string', - 'name' => 'required|string', - 'service_name' => 'nullable|string', - 'service_url' => 'nullable|string', - 'service_photo' => 'nullable|string', - 'user_name' => 'nullable|string', - 'user_url' => 'nullable|string', - 'user_photo' => 'nullable|string', - ]); - - SyndicationTarget::create($validated); - - return redirect('/admin/syndication'); - } - - /** - * Show a form to edit a syndication target. - */ - public function edit(SyndicationTarget $syndicationTarget): View - { - return view('admin.syndication.edit', [ - 'syndication_target' => $syndicationTarget, - ]); - } - - /** - * Process the request to edit a client name. - */ - public function update(Request $request, SyndicationTarget $syndicationTarget): RedirectResponse - { - $validated = $request->validate([ - 'uid' => 'required|string', - 'name' => 'required|string', - 'service_name' => 'nullable|string', - 'service_url' => 'nullable|string', - 'service_photo' => 'nullable|string', - 'user_name' => 'nullable|string', - 'user_url' => 'nullable|string', - 'user_photo' => 'nullable|string', - ]); - - $syndicationTarget->update($validated); - - return redirect('/admin/syndication'); - } - - /** - * Process a request to delete a client. - */ - public function destroy(SyndicationTarget $syndicationTarget): RedirectResponse - { - $syndicationTarget->delete(); - - return redirect('/admin/syndication'); - } -} diff --git a/app/Http/Controllers/ArticlesController.php b/app/Http/Controllers/ArticlesController.php index 725c5b91..6d16589a 100644 --- a/app/Http/Controllers/ArticlesController.php +++ b/app/Http/Controllers/ArticlesController.php @@ -10,28 +10,34 @@ use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use Jonnybarnes\IndieWeb\Numbers; -/** - * @psalm-suppress UnusedClass - */ class ArticlesController extends Controller { /** * Show all articles (with pagination). + * + * @param int|null $year + * @param int|null $month + * @return View */ - public function index(?int $year = null, ?int $month = null): View + public function index(int $year = null, int $month = null): View { $articles = Article::where('published', '1') - ->date($year, $month) - ->orderBy('updated_at', 'desc') - ->simplePaginate(5); + ->date($year, $month) + ->orderBy('updated_at', 'desc') + ->simplePaginate(5); return view('articles.index', compact('articles')); } /** * Show a single article. + * + * @param int $year + * @param int $month + * @param string $slug + * @return RedirectResponse|View */ - public function show(int $year, int $month, string $slug): RedirectResponse|View + public function show(int $year, int $month, string $slug) { try { $article = Article::where('titleurl', $slug)->firstOrFail(); @@ -50,13 +56,21 @@ class ArticlesController extends Controller } /** - * We only have the ID, work out post title, year and month and redirect to it. + * We only have the ID, work out post title, year and month + * and redirect to it. + * + * @param int $idFromUrl + * @return RedirectResponse */ - public function onlyIdInUrl(string $idFromUrl): RedirectResponse + public function onlyIdInUrl(int $idFromUrl): RedirectResponse { - $realId = resolve(Numbers::class)->b60tonum($idFromUrl); + $realId = resolve(Numbers::class)->b60tonum((string) $idFromUrl); - $article = Article::findOrFail($realId); + try { + $article = Article::findOrFail($realId); + } catch (ModelNotFoundException $exception) { + abort(404); + } return redirect($article->link); } diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 27f34eab..6efd237e 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -5,19 +5,17 @@ declare(strict_types=1); namespace App\Http\Controllers; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class AuthController extends Controller { /** * Show the login form. + * + * @return View|RedirectResponse */ - public function showLogin(): View|RedirectResponse + public function showLogin() { if (Auth::check()) { return redirect('/'); @@ -27,23 +25,28 @@ class AuthController extends Controller } /** - * Log in a user, set a session variable, check credentials against the `.env` file. + * Log in a user, set a session variable, check credentials against + * the .env file. + * + * @return RedirectResponse */ - public function login(Request $request): RedirectResponse + public function login(): RedirectResponse { - $credentials = $request->only('name', 'password'); + $credentials = request()->only('name', 'password'); if (Auth::attempt($credentials, true)) { - return redirect()->intended('/admin'); + return redirect()->intended('/'); } return redirect()->route('login'); } /** - * Show the form to allow a user to log-out. + * Show the form to logout a user. + * + * @return View|RedirectResponse */ - public function showLogout(): View|RedirectResponse + public function showLogout() { if (Auth::check() === false) { // The user is not logged in, just redirect them home @@ -55,6 +58,8 @@ class AuthController extends Controller /** * Log the user out from their current session. + * + * @return RedirectResponse; */ public function logout(): RedirectResponse { diff --git a/app/Http/Controllers/BookmarksController.php b/app/Http/Controllers/BookmarksController.php index ae9a0280..9b73e989 100644 --- a/app/Http/Controllers/BookmarksController.php +++ b/app/Http/Controllers/BookmarksController.php @@ -7,13 +7,12 @@ namespace App\Http\Controllers; use App\Models\Bookmark; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class BookmarksController extends Controller { /** * Show the most recent bookmarks. + * + * @return View */ public function index(): View { @@ -24,6 +23,9 @@ class BookmarksController extends Controller /** * Show a single bookmark. + * + * @param Bookmark $bookmark + * @return View */ public function show(Bookmark $bookmark): View { @@ -31,16 +33,4 @@ class BookmarksController extends Controller return view('bookmarks.show', compact('bookmark')); } - - /** - * Show bookmarks tagged with a specific tag. - */ - public function tagged(string $tag): View - { - $bookmarks = Bookmark::whereHas('tags', function ($query) use ($tag) { - $query->where('tag', $tag); - })->latest()->with('tags')->withCount('tags')->paginate(10); - - return view('bookmarks.tagged', compact('bookmarks', 'tag')); - } } diff --git a/app/Http/Controllers/ContactsController.php b/app/Http/Controllers/ContactsController.php index 503a75ff..d3869114 100644 --- a/app/Http/Controllers/ContactsController.php +++ b/app/Http/Controllers/ContactsController.php @@ -8,13 +8,12 @@ use App\Models\Contact; use Illuminate\Filesystem\Filesystem; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class ContactsController extends Controller { /** * Show all the contacts. + * + * @return View */ public function index(): View { @@ -34,6 +33,9 @@ class ContactsController extends Controller /** * Show a single contact. + * + * @param Contact $contact + * @return View */ public function show(Contact $contact): View { diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5c..ce1176dd 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,14 @@ namespace App\Http\Controllers; -abstract class Controller +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Foundation\Bus\DispatchesJobs; +use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Routing\Controller as BaseController; + +class Controller extends BaseController { - // + use AuthorizesRequests; + use DispatchesJobs; + use ValidatesRequests; } diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index 4e887105..b544d242 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -4,18 +4,16 @@ declare(strict_types=1); namespace App\Http\Controllers; -use App\Models\Article; -use App\Models\Note; +use App\Models\{Article, Note}; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; -/** - * @psalm-suppress UnusedClass - */ class FeedsController extends Controller { /** * Returns the blog RSS feed. + * + * @return Response */ public function blogRss(): Response { @@ -23,24 +21,28 @@ class FeedsController extends Controller $buildDate = $articles->first()->updated_at->toRssString(); return response() - ->view('articles.rss', compact('articles', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->view('articles.rss', compact('articles', 'buildDate')) + ->header('Content-Type', 'application/rss+xml; charset=utf-8'); } /** * Returns the blog Atom feed. + * + * @return Response */ public function blogAtom(): Response { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); return response() - ->view('articles.atom', compact('articles')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); + ->view('articles.atom', compact('articles')) + ->header('Content-Type', 'application/atom+xml; charset=utf-8'); } /** * Returns the notes RSS feed. + * + * @return Response */ public function notesRss(): Response { @@ -48,41 +50,39 @@ class FeedsController extends Controller $buildDate = $notes->first()->updated_at->toRssString(); return response() - ->view('notes.rss', compact('notes', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); + ->view('notes.rss', compact('notes', 'buildDate')) + ->header('Content-Type', 'application/rss+xml; charset=utf-8'); } /** * Returns the notes Atom feed. + * + * @return Response */ public function notesAtom(): Response { $notes = Note::latest()->take(20)->get(); return response() - ->view('notes.atom', compact('notes')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); + ->view('notes.atom', compact('notes')) + ->header('Content-Type', 'application/atom+xml; charset=utf-8'); } /** @todo sort out return type for json responses */ /** * Returns the blog JSON feed. + * + * @return array */ public function blogJson(): array { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1.1', - 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s blog', + 'version' => 'https://jsonfeed.org/version/1', + 'title' => 'The JSON Feed for ' . config('app.display_name') . '’s blog', 'home_page_url' => config('app.url') . '/blog', 'feed_url' => config('app.url') . '/blog/feed.json', - 'authors' => [ - [ - 'name' => config('user.display_name'), - 'url' => config('app.url'), - ], - ], 'items' => [], ]; @@ -94,6 +94,9 @@ class FeedsController extends Controller 'content_html' => $article->main, 'date_published' => $article->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(), + 'author' => [ + 'name' => config('app.display_name'), + ], ]; } @@ -102,21 +105,17 @@ class FeedsController extends Controller /** * Returns the notes JSON feed. + * + * @return array */ - public function notesJson(): array + public function notesJson() { - $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); + $notes = Note::latest()->take(20)->get(); $data = [ - 'version' => 'https://jsonfeed.org/version/1.1', - 'title' => 'The JSON Feed for ' . config('user.display_name') . '’s notes', + 'version' => 'https://jsonfeed.org/version/1', + 'title' => 'The JSON Feed for ' . config('app.display_name') . '’s notes', 'home_page_url' => config('app.url') . '/notes', 'feed_url' => config('app.url') . '/notes/feed.json', - 'authors' => [ - [ - 'name' => config('user.display_name'), - 'url' => config('app.url'), - ], - ], 'items' => [], ]; @@ -124,13 +123,13 @@ class FeedsController extends Controller $data['items'][$key] = [ 'id' => $note->longurl, 'url' => $note->longurl, - 'content_text' => $note->content, + 'content_html' => $note->content, 'date_published' => $note->created_at->tz('UTC')->toRfc3339String(), 'date_modified' => $note->updated_at->tz('UTC')->toRfc3339String(), + 'author' => [ + 'name' => config('app.display_name'), + ], ]; - if ($note->tags->count() > 0) { - $data['items'][$key]['tags'] = implode(',', $note->tags->pluck('tag')->toArray()); - } } return $data; @@ -138,6 +137,8 @@ class FeedsController extends Controller /** * Returns the blog JF2 feed. + * + * @return JsonResponse */ public function blogJf2(): JsonResponse { @@ -163,8 +164,8 @@ class FeedsController extends Controller 'url' => url('/blog'), 'author' => [ 'type' => 'card', - 'name' => config('user.display_name'), - 'url' => config('url.longurl'), + 'name' => config('user.displayname'), + 'url' => config('app.longurl'), ], 'children' => $items, ], 200, [ @@ -174,6 +175,8 @@ class FeedsController extends Controller /** * Returns the notes JF2 feed. + * + * @return JsonResponse */ public function notesJf2(): JsonResponse { @@ -199,8 +202,8 @@ class FeedsController extends Controller 'url' => url('/notes'), 'author' => [ 'type' => 'card', - 'name' => config('user.display_name'), - 'url' => config('url.longurl'), + 'name' => config('user.displayname'), + 'url' => config('app.longurl'), ], 'children' => $items, ], 200, [ diff --git a/app/Http/Controllers/FrontPageController.php b/app/Http/Controllers/FrontPageController.php index 8ae9c3c6..05731663 100644 --- a/app/Http/Controllers/FrontPageController.php +++ b/app/Http/Controllers/FrontPageController.php @@ -3,34 +3,29 @@ namespace App\Http\Controllers; use App\Models\Article; -use App\Models\Bio; use App\Models\Bookmark; use App\Models\Like; use App\Models\Note; +use App\Services\ActivityStreamsService; use Illuminate\Http\Response; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class FrontPageController extends Controller { /** * Show all the recent activity. + * + * @return Response|View */ - public function index(): Response|View + public function index() { - $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(); + if (request()->wantsActivityStream()) { + return (new ActivityStreamsService())->siteOwnerResponse(); + } + + $notes = Note::latest()->get(); $articles = Article::latest()->get(); - $bookmarks = Bookmark::latest()->with('tags')->get(); + $bookmarks = Bookmark::latest()->get(); $likes = Like::latest()->get(); $items = collect($notes) @@ -40,11 +35,8 @@ class FrontPageController extends Controller ->sortByDesc('updated_at') ->paginate(10); - $bio = Bio::first()?->content; - return view('front-page', [ 'items' => $items, - 'bio' => $bio, ]); } } diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php deleted file mode 100644 index c56fc59d..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' => $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 77d5f963..3bf5a1dd 100644 --- a/app/Http/Controllers/LikesController.php +++ b/app/Http/Controllers/LikesController.php @@ -7,13 +7,12 @@ namespace App\Http\Controllers; use App\Models\Like; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class LikesController extends Controller { /** * Show the latest likes. + * + * @return View */ public function index(): View { @@ -24,6 +23,9 @@ class LikesController extends Controller /** * Show a single like. + * + * @param Like $like + * @return View */ public function show(Like $like): View { diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 8a395ee0..1f6ea4d4 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -4,32 +4,20 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Exceptions\InvalidTokenException; use App\Http\Responses\MicropubResponses; use App\Models\Place; -use App\Models\SyndicationTarget; -use App\Services\Micropub\HCardService; -use App\Services\Micropub\HEntryService; -use App\Services\Micropub\UpdateService; +use App\Services\Micropub\{HCardService, HEntryService, UpdateService}; use App\Services\TokenService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Lcobucci\JWT\Encoding\CannotDecodeContent; -use Lcobucci\JWT\Token\InvalidTokenStructure; -use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use Monolog\Handler\StreamHandler; use Monolog\Logger; -/** - * @psalm-suppress UnusedClass - */ class MicropubController extends Controller { protected TokenService $tokenService; - protected HEntryService $hentryService; - protected HCardService $hcardService; - protected UpdateService $updateService; public function __construct( @@ -47,37 +35,35 @@ class MicropubController extends Controller /** * This function receives an API request, verifies the authenticity * then passes over the info to the relevant Service class. + * + * @return JsonResponse + * @throws InvalidTokenException */ - public function post(Request $request): JsonResponse + public function post(): JsonResponse { try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { + $tokenData = $this->tokenService->validateToken(request()->input('access_token')); + } catch (InvalidTokenException $e) { $micropubResponses = new MicropubResponses(); return $micropubResponses->invalidTokenResponse(); } - if ($tokenData->claims()->has('scope') === false) { + if ($tokenData->hasClaim('scope') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->tokenHasNoScopeResponse(); } - $this->logMicropubRequest($request->all()); + $this->logMicropubRequest(request()->all()); - if (($request->input('h') === 'entry') || ($request->input('type.0') === 'h-entry')) { - $scopes = $tokenData->claims()->get('scope'); - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } - - if (! in_array('create', $scopes)) { + if ((request()->input('h') == 'entry') || (request()->input('type.0') == 'h-entry')) { + if (stristr($tokenData->getClaim('scope'), 'create') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->insufficientScopeResponse(); } - $location = $this->hentryService->process($request->all(), $this->getCLientId()); + $location = $this->hentryService->process(request()->all(), $this->getCLientId()); return response()->json([ 'response' => 'created', @@ -85,17 +71,13 @@ class MicropubController extends Controller ], 201)->header('Location', $location); } - if ($request->input('h') === 'card' || $request->input('type.0') === 'h-card') { - $scopes = $tokenData->claims()->get('scope'); - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } - if (! in_array('create', $scopes)) { + if (request()->input('h') == 'card' || request()->input('type.0') == 'h-card') { + if (stristr($tokenData->getClaim('scope'), 'create') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->insufficientScopeResponse(); } - $location = $this->hcardService->process($request->all()); + $location = $this->hcardService->process(request()->all()); return response()->json([ 'response' => 'created', @@ -103,18 +85,14 @@ class MicropubController extends Controller ], 201)->header('Location', $location); } - if ($request->input('action') === 'update') { - $scopes = $tokenData->claims()->get('scope'); - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } - if (! in_array('update', $scopes)) { + if (request()->input('action') == 'update') { + if (stristr($tokenData->getClaim('scope'), 'update') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->insufficientScopeResponse(); } - return $this->updateService->process($request->all()); + return $this->updateService->process(request()->all()); } return response()->json([ @@ -130,35 +108,39 @@ class MicropubController extends Controller * token, here we check whether the token is valid and respond * appropriately. Further if the request has the query parameter * syndicate-to we respond with the known syndication endpoints. + * + * @return JsonResponse */ - public function get(Request $request): JsonResponse + public function get(): JsonResponse { try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure) { - return (new MicropubResponses())->invalidTokenResponse(); + $tokenData = $this->tokenService->validateToken(request()->input('access_token')); + } catch (InvalidTokenException $e) { + $micropubResponses = new MicropubResponses(); + + return $micropubResponses->invalidTokenResponse(); } - if ($request->input('q') === 'syndicate-to') { + if (request()->input('q') === 'syndicate-to') { return response()->json([ - 'syndicate-to' => SyndicationTarget::all(), + 'syndicate-to' => config('syndication.targets'), ]); } - if ($request->input('q') === 'config') { + if (request()->input('q') == 'config') { return response()->json([ - 'syndicate-to' => SyndicationTarget::all(), + 'syndicate-to' => config('syndication.targets'), 'media-endpoint' => route('media-endpoint'), ]); } - if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) { + if (request()->has('q') && substr(request()->input('q'), 0, 4) === 'geo:') { preg_match_all( '/([0-9.\-]+)/', - $request->input('q'), + request()->input('q'), $matches ); - $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; + $distance = (count($matches[0]) == 3) ? 100 * $matches[0][2] : 1000; $places = Place::near( (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], $distance @@ -174,9 +156,9 @@ class MicropubController extends Controller return response()->json([ 'response' => 'token', 'token' => [ - 'me' => $tokenData->claims()->get('me'), - 'scope' => $tokenData->claims()->get('scope'), - 'client_id' => $tokenData->claims()->get('client_id'), + 'me' => $tokenData->getClaim('me'), + 'scope' => $tokenData->getClaim('scope'), + 'client_id' => $tokenData->getClaim('client_id'), ], ]); } @@ -184,19 +166,22 @@ class MicropubController extends Controller /** * Determine the client id from the access token sent with the request. * - * @throws RequiredConstraintsViolated + * @return string + * @throws InvalidTokenException */ private function getClientId(): string { return resolve(TokenService::class) - ->validateToken(app('request')->input('access_token')) - ->claims()->get('client_id'); + ->validateToken(request()->input('access_token')) + ->getClaim('client_id'); } /** * Save the details of the micropub request to a log file. + * + * @param array $request This is the info from request()->all() */ - private function logMicropubRequest(array $request): void + private function logMicropubRequest(array $request) { $logger = new Logger('micropub'); $logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log'))); diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index e07e979f..22df7575 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -4,27 +4,25 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Exceptions\InvalidTokenException; 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\Database\Eloquent\ModelNotFoundException; 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; @@ -34,65 +32,59 @@ class MicropubMediaController extends Controller $this->tokenService = $tokenService; } - public function getHandler(Request $request): JsonResponse + public function getHandler(): JsonResponse { try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure) { + $tokenData = $this->tokenService->validateToken(request()->input('access_token')); + } catch (InvalidTokenException $e) { $micropubResponses = new MicropubResponses(); return $micropubResponses->invalidTokenResponse(); } - if ($tokenData->claims()->has('scope') === false) { + if ($tokenData->hasClaim('scope') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->tokenHasNoScopeResponse(); } - $scopes = $tokenData->claims()->get('scope'); - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } - if (! in_array('create', $scopes)) { + if (Str::contains($tokenData->getClaim('scope'), 'create') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->insufficientScopeResponse(); } - if ($request->input('q') === 'last') { - $media = Media::where('created_at', '>=', Carbon::now()->subMinutes(30)) - ->where('token', $request->input('access_token')) - ->latest() - ->first(); - $mediaUrl = $media?->url; + if (request()->input('q') === 'last') { + try { + $media = Media::latest()->whereDate('created_at', '>=', Carbon::now()->subMinutes(30))->firstOrFail(); + } catch (ModelNotFoundException $exception) { + return response()->json(['url' => null]); + } - return response()->json(['url' => $mediaUrl]); + return response()->json(['url' => $media->url]); } - if ($request->input('q') === 'source') { - $limit = $request->input('limit', 10); - $offset = $request->input('offset', 0); + if (request()->input('q') === 'source') { + $limit = request()->input('limit', 10); + $offset = request()->input('offset', 0); $media = Media::latest()->offset($offset)->limit($limit)->get(); $media->transform(function ($mediaItem) { return [ 'url' => $mediaItem->url, - 'published' => $mediaItem->created_at->toW3cString(), - 'mime_type' => $mediaItem->mimetype, ]; }); return response()->json(['items' => $media]); } - if ($request->has('q')) { + if (request()->has('q')) { return response()->json([ 'error' => 'invalid_request', 'error_description' => sprintf( 'This server does not know how to handle this q parameter (%s)', - $request->input('q') + request()->input('q') ), ], 400); } @@ -103,36 +95,33 @@ class MicropubMediaController extends Controller /** * Process a media item posted to the media endpoint. * + * @return JsonResponse * @throws BindingResolutionException * @throws Exception */ - public function media(Request $request): JsonResponse + public function media(): JsonResponse { try { - $tokenData = $this->tokenService->validateToken($request->input('access_token')); - } catch (RequiredConstraintsViolated|InvalidTokenStructure) { + $tokenData = $this->tokenService->validateToken(request()->input('access_token')); + } catch (InvalidTokenException $e) { $micropubResponses = new MicropubResponses(); return $micropubResponses->invalidTokenResponse(); } - if ($tokenData->claims()->has('scope') === false) { + if ($tokenData->hasClaim('scope') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->tokenHasNoScopeResponse(); } - $scopes = $tokenData->claims()->get('scope'); - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } - if (! in_array('create', $scopes)) { + if (Str::contains($tokenData->getClaim('scope'), 'create') === false) { $micropubResponses = new MicropubResponses(); return $micropubResponses->insufficientScopeResponse(); } - if ($request->hasFile('file') === false) { + if (request()->hasFile('file') === false) { return response()->json([ 'response' => 'error', 'error' => 'invalid_request', @@ -140,7 +129,7 @@ class MicropubMediaController extends Controller ], 400); } - if ($request->file('file')->isValid() === false) { + if (request()->file('file')->isValid() === false) { return response()->json([ 'response' => 'error', 'error' => 'invalid_request', @@ -148,22 +137,21 @@ class MicropubMediaController extends Controller ], 400); } - $filename = $this->saveFile($request->file('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->bearerToken(), + 'token' => request()->bearerToken(), 'path' => 'media/' . $filename, - 'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()), + 'type' => $this->getFileTypeFromMimeType(request()->file('file')->getMimeType()), 'image_widths' => $width, ]); @@ -184,6 +172,8 @@ class MicropubMediaController extends Controller /** * Return the relevant CORS headers to a pre-flight OPTIONS request. + * + * @return Response */ public function mediaOptionsResponse(): Response { @@ -192,6 +182,9 @@ class MicropubMediaController extends Controller /** * Get the file type from the mime-type of the uploaded file. + * + * @param string $mimeType + * @return string */ private function getFileTypeFromMimeType(string $mimeType): string { @@ -235,6 +228,8 @@ class MicropubMediaController extends Controller /** * Save an uploaded file to the local disk. * + * @param UploadedFile $file + * @return string * @throws Exception */ private function saveFile(UploadedFile $file): string diff --git a/app/Http/Controllers/NotesController.php b/app/Http/Controllers/NotesController.php index bef422cb..4b60c256 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Note; +use App\Services\ActivityStreamsService; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -12,28 +13,25 @@ use Illuminate\Http\Response; use Illuminate\View\View; use Jonnybarnes\IndieWeb\Numbers; -/** - * @todo Need to sort out Twitter and webmentions! - * - * @psalm-suppress UnusedClass - */ +// Need to sort out Twitter and webmentions! + class NotesController extends Controller { /** * Show all the notes. This is also the homepage. + * + * @return View|Response */ - public function index(): View|Response + public function index() { + if (request()->wantsActivityStream()) { + return (new ActivityStreamsService())->siteOwnerResponse(); + } + $notes = Note::latest() ->with('place', 'media', 'client') - ->withCount(['webmentions AS replies' => function ($query) { + ->withCount(['webmentions As replies' => function ($query) { $query->where('type', 'in-reply-to'); - }]) - ->withCount(['webmentions AS likes' => function ($query) { - $query->where('type', 'like-of'); - }]) - ->withCount(['webmentions AS reposts' => function ($query) { - $query->where('type', 'repost-of'); }])->paginate(10); return view('notes.index', compact('notes')); @@ -41,29 +39,30 @@ class NotesController extends Controller /** * Show a single note. + * + * @param string $urlId The id of the note + * @return View|JsonResponse|Response */ - public function show(string $urlId): View|JsonResponse|Response + public function show(string $urlId) { try { - $note = Note::nb60($urlId)->with('place', 'media', 'client') - ->withCount(['webmentions AS replies' => function ($query) { - $query->where('type', 'in-reply-to'); - }]) - ->withCount(['webmentions AS likes' => function ($query) { - $query->where('type', 'like-of'); - }]) - ->withCount(['webmentions AS reposts' => function ($query) { - $query->where('type', 'repost-of'); - }])->firstOrFail(); + $note = Note::nb60($urlId)->with('webmentions')->firstOrFail(); } catch (ModelNotFoundException $exception) { abort(404); } + if (request()->wantsActivityStream()) { + return (new ActivityStreamsService())->singleNoteResponse($note); + } + return view('notes.show', compact('note')); } /** * Redirect /note/{decID} to /notes/{nb60id}. + * + * @param int $decId The decimal id of the note + * @return RedirectResponse */ public function redirect(int $decId): RedirectResponse { @@ -72,6 +71,9 @@ class NotesController extends Controller /** * Show all notes tagged with {tag}. + * + * @param string $tag + * @return View */ public function tagged(string $tag): View { @@ -81,14 +83,4 @@ class NotesController extends Controller return view('notes.tagged', compact('notes', 'tag')); } - - /** - * Page to create a new note. - * - * Dummy page for now. - */ - public function create(): View - { - return view('notes.create'); - } } diff --git a/app/Http/Controllers/PlacesController.php b/app/Http/Controllers/PlacesController.php index b9bae93b..7f6ea928 100644 --- a/app/Http/Controllers/PlacesController.php +++ b/app/Http/Controllers/PlacesController.php @@ -7,13 +7,12 @@ namespace App\Http\Controllers; use App\Models\Place; use Illuminate\View\View; -/** - * @psalm-suppress UnusedClass - */ class PlacesController extends Controller { /** * Show all the places. + * + * @return View */ public function index(): View { @@ -24,6 +23,9 @@ class PlacesController extends Controller /** * Show a specific place. + * + * @param Place $place + * @return View */ public function show(Place $place): View { diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index a8116c88..773f270c 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -1,37 +1,23 @@ input('q'); + $notes = Note::search(request()->input('terms'))->paginate(10); - $notes = Note::search($search) - ->paginate(); - - /** @var Note $note */ - foreach ($notes as $note) { - $note->load('place', 'media', 'client') - ->loadCount(['webmentions AS replies' => function ($query) { - $query->where('type', 'in-reply-to'); - }]) - ->loadCount(['webmentions AS likes' => function ($query) { - $query->where('type', 'like-of'); - }]) - ->loadCount(['webmentions AS reposts' => function ($query) { - $query->where('type', 'repost-of'); - }]); - } - - return view('search', compact('search', 'notes')); + return view('search', compact('notes')); } } diff --git a/app/Http/Controllers/SessionStoreController.php b/app/Http/Controllers/SessionStoreController.php new file mode 100644 index 00000000..24665e1f --- /dev/null +++ b/app/Http/Controllers/SessionStoreController.php @@ -0,0 +1,22 @@ +input('css'); + + session(['css' => $css]); + + return ['status' => 'ok']; + } +} diff --git a/app/Http/Controllers/ShortURLsController.php b/app/Http/Controllers/ShortURLsController.php index a232fcdb..e93f51ac 100644 --- a/app/Http/Controllers/ShortURLsController.php +++ b/app/Http/Controllers/ShortURLsController.php @@ -6,9 +6,6 @@ namespace App\Http\Controllers; use Illuminate\Http\RedirectResponse; -/** - * @psalm-suppress UnusedClass - */ class ShortURLsController extends Controller { /* @@ -22,6 +19,8 @@ class ShortURLsController extends Controller /** * Redirect from '/' to the long url. + * + * @return RedirectResponse */ public function baseURL(): RedirectResponse { @@ -30,6 +29,8 @@ class ShortURLsController extends Controller /** * Redirect from '/@' to a twitter profile. + * + * @return RedirectResponse */ public function twitter(): RedirectResponse { @@ -38,15 +39,18 @@ class ShortURLsController extends Controller /** * Redirect a short url of this site out to a long one based on post type. - * * Further redirects may happen. + * + * @param string Post type + * @param string Post ID + * @return RedirectResponse */ public function expandType(string $type, string $postId): RedirectResponse { - if ($type === 't') { + if ($type == 't') { $type = 'notes'; } - if ($type === 'b') { + if ($type == 'b') { $type = 'blog/s'; } diff --git a/app/Http/Controllers/TokenEndpointController.php b/app/Http/Controllers/TokenEndpointController.php new file mode 100644 index 00000000..a6a17311 --- /dev/null +++ b/app/Http/Controllers/TokenEndpointController.php @@ -0,0 +1,80 @@ +client = $client; + $this->tokenService = $tokenService; + } + + /** + * If the user has auth’d via the IndieAuth protocol, issue a valid token. + * + * @return JsonResponse + */ + public function create(): JsonResponse + { + $authorizationEndpoint = $this->client->discoverAuthorizationEndpoint(normalize_url(request()->input('me'))); + if ($authorizationEndpoint) { + $auth = $this->client->verifyIndieAuthCode( + $authorizationEndpoint, + request()->input('code'), + request()->input('me'), + request()->input('redirect_uri'), + request()->input('client_id'), + null // code_verifier + ); + if (array_key_exists('me', $auth)) { + $scope = $auth['scope'] ?? ''; + $tokenData = [ + 'me' => request()->input('me'), + 'client_id' => request()->input('client_id'), + 'scope' => $scope, + ]; + $token = $this->tokenService->getNewToken($tokenData); + $content = [ + 'me' => request()->input('me'), + 'scope' => $scope, + 'access_token' => $token, + ]; + + return response()->json($content); + } + + return response()->json([ + 'error' => 'There was an error verifying the authorisation code.', + ], 401); + } + + return response()->json([ + 'error' => 'Can’t determine the authorisation endpoint.', + ], 400); + } +} diff --git a/app/Http/Controllers/WebMentionsController.php b/app/Http/Controllers/WebMentionsController.php index 700a7e23..2b2905db 100644 --- a/app/Http/Controllers/WebMentionsController.php +++ b/app/Http/Controllers/WebMentionsController.php @@ -7,14 +7,10 @@ namespace App\Http\Controllers; use App\Jobs\ProcessWebMention; use App\Models\Note; use Illuminate\Database\Eloquent\ModelNotFoundException; -use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\View\View; use Jonnybarnes\IndieWeb\Numbers; -/** - * @psalm-suppress UnusedClass - */ class WebMentionsController extends Controller { /** @@ -22,6 +18,8 @@ class WebMentionsController extends Controller * * This is probably someone looking for information about what * webmentions are, or about my particular implementation. + * + * @return View */ public function get(): View { @@ -30,11 +28,13 @@ class WebMentionsController extends Controller /** * Receive and process a webmention. + * + * @return Response */ - public function receive(Request $request): Response + public function receive(): Response { //first we trivially reject requests that lack all required inputs - if (($request->has('target') !== true) || ($request->has('source') !== true)) { + if ((request()->has('target') !== true) || (request()->has('source') !== true)) { return response( 'You need both the target and source parameters', 400 @@ -42,15 +42,15 @@ class WebMentionsController extends Controller } //next check the $target is valid - $path = parse_url($request->input('target'), PHP_URL_PATH); + $path = parse_url(request()->input('target'), PHP_URL_PATH); $pathParts = explode('/', $path); - if ($pathParts[1] === 'notes') { + if ($pathParts[1] == 'notes') { //we have a note $noteId = $pathParts[2]; try { $note = Note::findOrFail(resolve(Numbers::class)->b60tonum($noteId)); - dispatch(new ProcessWebMention($note, $request->input('source'))); + dispatch(new ProcessWebMention($note, request()->input('source'))); } catch (ModelNotFoundException $e) { return response('This note doesn’t exist.', 400); } @@ -60,7 +60,7 @@ class WebMentionsController extends Controller 202 ); } - if ($pathParts[1] === 'blog') { + if ($pathParts[1] == 'blog') { return response( 'I don’t accept webmentions for blog posts yet.', 501 diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php new file mode 100644 index 00000000..6104d941 --- /dev/null +++ b/app/Http/Kernel.php @@ -0,0 +1,73 @@ + [ + \App\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + // \Illuminate\Session\Middleware\AuthenticateSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\VerifyCsrfToken::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + \App\Http\Middleware\LinkHeadersMiddleware::class, + \App\Http\Middleware\LocalhostSessionMiddleware::class, + \App\Http\Middleware\ActivityStreamLinks::class, + \App\Http\Middleware\CSPHeader::class, + ], + + 'api' => [ + 'throttle:api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ], + ]; + + /** + * The application's route middleware. + * + * These middleware may be assigned to groups or used individually. + * + * @var array + */ + protected $routeMiddleware = [ + 'auth' => \App\Http\Middleware\Authenticate::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, + 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + 'micropub.token' => \App\Http\Middleware\VerifyMicropubToken::class, + 'myauth' => \App\Http\Middleware\MyAuthMiddleware::class, + 'cors' => \App\Http\Middleware\CorsHeaders::class, + ]; +} diff --git a/app/Http/Middleware/ActivityStreamLinks.php b/app/Http/Middleware/ActivityStreamLinks.php new file mode 100644 index 00000000..4cad411f --- /dev/null +++ b/app/Http/Middleware/ActivityStreamLinks.php @@ -0,0 +1,31 @@ +path() === '/') { + $response->header('Link', '<' . config('app.url') . '>; rel="application/activity+json"', false); + } + if ($request->is('notes/*')) { + $response->header('Link', '<' . $request->url() . '>; rel="application/activity+json"', false); + } + + return $response; + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 624cd371..a4be5c58 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -3,18 +3,19 @@ namespace App\Http\Middleware; use Illuminate\Auth\Middleware\Authenticate as Middleware; -use Illuminate\Http\Request; -/** - * @codeCoverageIgnore - */ class Authenticate extends Middleware { /** * Get the path the user should be redirected to when they are not authenticated. + * + * @param \Illuminate\Http\Request $request + * @return string */ - protected function redirectTo(Request $request): ?string + protected function redirectTo($request) { - return $request->expectsJson() ? null : route('login'); + if (! $request->expectsJson()) { + return route('login'); + } } } diff --git a/app/Http/Middleware/CSPHeader.php b/app/Http/Middleware/CSPHeader.php new file mode 100644 index 00000000..649f3a03 --- /dev/null +++ b/app/Http/Middleware/CSPHeader.php @@ -0,0 +1,48 @@ +header( + 'Content-Security-Policy', + "default-src 'self'; " . + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api.mapbox.com https://api.tiles.mapbox.com blob:; " . + "style-src 'self' 'unsafe-inline' https://api.mapbox.com https://api.tiles.mapbox.com cloud.typography.com jonnybarnes.uk; " . + "img-src 'self' data: blob: https://pbs.twimg.com https://api.mapbox.com https://*.tiles.mapbox.com https://jbuk-media.s3-eu-west-1.amazonaws.com https://jbuk-media-dev.s3-eu-west-1.amazonaws.com https://secure.gravatar.com https://graph.facebook.com *.fbcdn.net https://*.cdninstagram.com https://*.4sqi.net https://upload.wikimedia.org; " . + "font-src 'self' data:; " . + "connect-src 'self' https://api.mapbox.com https://*.tiles.mapbox.com https://events.mapbox.com data: blob:; " . + "worker-src 'self' blob:; " . + "frame-src 'self' https://www.youtube.com blob:; " . + 'child-src 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 + } +} diff --git a/app/Http/Middleware/CheckForMaintenanceMode.php b/app/Http/Middleware/CheckForMaintenanceMode.php new file mode 100644 index 00000000..35b9824b --- /dev/null +++ b/app/Http/Middleware/CheckForMaintenanceMode.php @@ -0,0 +1,17 @@ +path() === 'api/media') { diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php index 867695bd..033136ad 100644 --- a/app/Http/Middleware/EncryptCookies.php +++ b/app/Http/Middleware/EncryptCookies.php @@ -9,7 +9,7 @@ class EncryptCookies extends Middleware /** * The names of the cookies that should not be encrypted. * - * @var array + * @var array */ protected $except = [ // diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index 879020be..66dee526 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -3,24 +3,23 @@ namespace App\Http\Middleware; use Closure; -use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\Response; class LinkHeadersMiddleware { /** * Handle an incoming request. * - * @psalm-suppress PossiblyUnusedMethod + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed */ - public function handle(Request $request, Closure $next): Response + public function handle($request, Closure $next) { $response = $next($request); - $response->header('Link', '<' . route('indieauth.metadata') . '>; rel="indieauth-metadata"', false); - $response->header('Link', '<' . route('indieauth.start') . '>; rel="authorization_endpoint"', false); - $response->header('Link', '<' . route('indieauth.token') . '>; rel="token_endpoint"', false); - $response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false); - $response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false); + $response->header('Link', '; rel="authorization_endpoint"', false); + $response->header('Link', '<' . config('app.url') . '/api/token>; rel="token_endpoint"', false); + $response->header('Link', '<' . config('app.url') . '/api/post>; rel="micropub"', false); + $response->header('Link', '<' . config('app.url') . '/webmention>; rel="webmention"', false); return $response; } diff --git a/app/Http/Middleware/LocalhostSessionMiddleware.php b/app/Http/Middleware/LocalhostSessionMiddleware.php index c7d8ac4c..5131b9fc 100644 --- a/app/Http/Middleware/LocalhostSessionMiddleware.php +++ b/app/Http/Middleware/LocalhostSessionMiddleware.php @@ -6,7 +6,6 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\Response; class LocalhostSessionMiddleware { @@ -15,9 +14,11 @@ class LocalhostSessionMiddleware * `['me' => config('app.url')]` as I can’t manually log in as * a .localhost domain. * - * @psalm-suppress PossiblyUnusedMethod + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed */ - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, Closure $next) { if (config('app.env') !== 'production') { session(['me' => config('app.url')]); diff --git a/app/Http/Middleware/MyAuthMiddleware.php b/app/Http/Middleware/MyAuthMiddleware.php index e455d181..872e6846 100644 --- a/app/Http/Middleware/MyAuthMiddleware.php +++ b/app/Http/Middleware/MyAuthMiddleware.php @@ -7,21 +7,20 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Symfony\Component\HttpFoundation\Response; class MyAuthMiddleware { /** * Check the user is logged in. * - * @psalm-suppress PossiblyUnusedMethod + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed */ - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, Closure $next) { - if (Auth::check() === false) { - // they’re not logged in, so send them to login form - redirect()->setIntendedUrl($request->fullUrl()); - + if (Auth::check($request->user()) == false) { + //they’re not logged in, so send them to login form return redirect()->route('login'); } diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php index 74cbd9a9..e4956d0b 100644 --- a/app/Http/Middleware/PreventRequestsDuringMaintenance.php +++ b/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -9,7 +9,7 @@ class PreventRequestsDuringMaintenance extends Middleware /** * The URIs that should be reachable while maintenance mode is enabled. * - * @var array + * @var array */ protected $except = [ // diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index a6a6c8c4..362b48b0 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -6,19 +6,18 @@ use App\Providers\RouteServiceProvider; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Symfony\Component\HttpFoundation\Response; -/** - * @codeCoverageIgnore - */ class RedirectIfAuthenticated { /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string|null ...$guards + * @return mixed */ - public function handle(Request $request, Closure $next, string ...$guards): Response + public function handle(Request $request, Closure $next, ...$guards) { $guards = empty($guards) ? [null] : $guards; diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php index 88cadcaa..5a50e7b5 100644 --- a/app/Http/Middleware/TrimStrings.php +++ b/app/Http/Middleware/TrimStrings.php @@ -9,10 +9,9 @@ class TrimStrings extends Middleware /** * The names of the attributes that should not be trimmed. * - * @var array + * @var array */ protected $except = [ - 'current_password', 'password', 'password_confirmation', ]; diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index 9c88c34c..b0550cfc 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -4,17 +4,14 @@ namespace App\Http\Middleware; use Illuminate\Http\Middleware\TrustHosts as Middleware; -/** - * @codeCoverageIgnore - */ class TrustHosts extends Middleware { /** * Get the host patterns that should be trusted. * - * @return array + * @return array */ - public function hosts(): array + public function hosts() { return [ $this->allSubdomainsOfApplicationUrl(), diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index f33f3eef..0f7ae419 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -2,7 +2,7 @@ namespace App\Http\Middleware; -use Illuminate\Http\Middleware\TrustProxies as Middleware; +use Fideloper\Proxy\TrustProxies as Middleware; use Illuminate\Http\Request; class TrustProxies extends Middleware @@ -10,7 +10,7 @@ class TrustProxies extends Middleware /** * The trusted proxies for this application. * - * @var array|string|null + * @var array|string */ protected $proxies; @@ -19,10 +19,5 @@ class TrustProxies extends Middleware * * @var int */ - protected $headers = - Request::HEADER_X_FORWARDED_FOR | - Request::HEADER_X_FORWARDED_HOST | - Request::HEADER_X_FORWARDED_PORT | - Request::HEADER_X_FORWARDED_PROTO | - Request::HEADER_X_FORWARDED_AWS_ELB; + protected $headers = Request::HEADER_X_FORWARDED_ALL; } diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php deleted file mode 100644 index 2beb3c93..00000000 --- a/app/Http/Middleware/ValidateSignature.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * @psalm-suppress PossiblyUnusedProperty - */ - protected $except = [ - // 'fbclid', - // 'utm_campaign', - // 'utm_content', - // 'utm_medium', - // 'utm_source', - // 'utm_term', - ]; -} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index fc7bad50..1593e373 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -9,7 +9,7 @@ class VerifyCsrfToken extends Middleware /** * The URIs that should be excluded from CSRF verification. * - * @var array + * @var array */ protected $except = [ 'api/media', diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index b68e999b..aa650560 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -6,16 +6,17 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\Response; class VerifyMicropubToken { /** * Handle an incoming request. * - * @psalm-suppress PossiblyUnusedMethod + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed */ - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, Closure $next) { if ($request->input('access_token')) { return $next($request); diff --git a/app/Http/Responses/MicropubResponses.php b/app/Http/Responses/MicropubResponses.php index 4f7240c2..829e5c57 100644 --- a/app/Http/Responses/MicropubResponses.php +++ b/app/Http/Responses/MicropubResponses.php @@ -10,6 +10,8 @@ class MicropubResponses { /** * Generate a response to be returned when the token has insufficient scope. + * + * @return JsonResponse */ public function insufficientScopeResponse(): JsonResponse { @@ -22,6 +24,8 @@ class MicropubResponses /** * Generate a response to be returned when the token is invalid. + * + * @return JsonResponse */ public function invalidTokenResponse(): JsonResponse { @@ -34,6 +38,8 @@ class MicropubResponses /** * Generate a response to be returned when the token has no scope. + * + * @return JsonResponse */ public function tokenHasNoScopeResponse(): JsonResponse { diff --git a/app/Jobs/AddClientToDatabase.php b/app/Jobs/AddClientToDatabase.php index b540aac0..1263b0fa 100644 --- a/app/Jobs/AddClientToDatabase.php +++ b/app/Jobs/AddClientToDatabase.php @@ -18,22 +18,26 @@ class AddClientToDatabase implements ShouldQueue use Queueable; use SerializesModels; - protected string $client_id; + protected $client_id; /** * Create a new job instance. + * + * @param string $client_id */ - public function __construct(string $clientId) + public function __construct(string $client_id) { - $this->client_id = $clientId; + $this->client_id = $client_id; } /** * Execute the job. + * + * @return void */ - public function handle(): void + public function handle() { - if (MicropubClient::where('client_url', $this->client_id)->count() === 0) { + if (MicropubClient::where('client_url', $this->client_id)->count() == 0) { MicropubClient::create([ 'client_url' => $this->client_id, 'client_name' => $this->client_id, // default client name is the URL diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php index a32b25a9..087dab50 100644 --- a/app/Jobs/DownloadWebMention.php +++ b/app/Jobs/DownloadWebMention.php @@ -20,24 +20,35 @@ class DownloadWebMention implements ShouldQueue use SerializesModels; /** - * Create a new job instance. + * The webmention source URL. + * + * @var string */ - public function __construct( - protected string $source - ) {} + protected $source; + + /** + * Create a new job instance. + * + * @param string $source + */ + public function __construct(string $source) + { + $this->source = $source; + } /** * Execute the job. * + * @param Client $guzzle * @throws GuzzleException * @throws FileNotFoundException */ - public function handle(Client $guzzle): void + public function handle(Client $guzzle) { $response = $guzzle->request('GET', $this->source); //4XX and 5XX responses should get Guzzle to throw an exception, //Laravel should catch and retry these automatically. - if ($response->getStatusCode() === 200) { + if ($response->getStatusCode() == '200') { $filesystem = new FileSystem(); $filename = storage_path('HTML') . '/' . $this->createFilenameFromURL($this->source); //backup file first @@ -60,7 +71,7 @@ class DownloadWebMention implements ShouldQueue ); //remove backup if the same if ($filesystem->exists($filenameBackup)) { - if ($filesystem->get($filename) === $filesystem->get($filenameBackup)) { + if ($filesystem->get($filename) == $filesystem->get($filenameBackup)) { $filesystem->delete($filenameBackup); } } @@ -69,11 +80,14 @@ class DownloadWebMention implements ShouldQueue /** * Create a file path from a URL. This is used when caching the HTML response. + * + * @param string $url + * @return string The path name */ - private function createFilenameFromURL(string $url): string + private function createFilenameFromURL(string $url) { $filepath = str_replace(['https://', 'http://'], ['https/', 'http/'], $url); - if (str_ends_with($filepath, '/')) { + if (substr($filepath, -1) == '/') { $filepath .= 'index.html'; } diff --git a/app/Jobs/ProcessBookmark.php b/app/Jobs/ProcessBookmark.php index 96f65e87..d38edcd6 100644 --- a/app/Jobs/ProcessBookmark.php +++ b/app/Jobs/ProcessBookmark.php @@ -20,23 +20,32 @@ class ProcessBookmark implements ShouldQueue use Queueable; use SerializesModels; + /** @var Bookmark */ + protected $bookmark; + /** * Create a new job instance. + * + * @param Bookmark $bookmark */ - public function __construct( - protected Bookmark $bookmark - ) {} + public function __construct(Bookmark $bookmark) + { + $this->bookmark = $bookmark; + } /** * Execute the job. + * + * @return void */ - public function handle(): void + public function handle() { - SaveScreenshot::dispatch($this->bookmark); + $uuid = (resolve(BookmarkService::class))->saveScreenshot($this->bookmark->url); + $this->bookmark->screenshot = $uuid; try { $archiveLink = (resolve(BookmarkService::class))->getArchiveLink($this->bookmark->url); - } catch (InternetArchiveException) { + } catch (InternetArchiveException $e) { $archiveLink = null; } $this->bookmark->archive = $archiveLink; diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php index 37a377a3..976ad010 100644 --- a/app/Jobs/ProcessLike.php +++ b/app/Jobs/ProcessLike.php @@ -25,16 +25,25 @@ class ProcessLike implements ShouldQueue use Queueable; use SerializesModels; + /** @var Like */ + protected $like; + /** * Create a new job instance. + * + * @param Like $like */ - public function __construct( - protected Like $like - ) {} + public function __construct(Like $like) + { + $this->like = $like; + } /** * Execute the job. * + * @param Client $client + * @param Authorship $authorship + * @return int * @throws GuzzleException */ public function handle(Client $client, Authorship $authorship): int @@ -51,7 +60,7 @@ class ProcessLike implements ShouldQueue //POSSE like try { - $client->request( + $response = $client->request( 'POST', 'https://brid.gy/publish/webmention', [ @@ -61,8 +70,8 @@ class ProcessLike implements ShouldQueue ], ] ); - } catch (RequestException) { - return 0; + } catch (RequestException $exception) { + //no biggie } return 0; @@ -94,6 +103,9 @@ class ProcessLike implements ShouldQueue /** * Determine if a given URL is that of a Tweet. + * + * @param string $url + * @return bool */ private function isTweet(string $url): bool { diff --git a/app/Jobs/ProcessMedia.php b/app/Jobs/ProcessMedia.php index 6b6a1dcf..4fadb397 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 @@ -20,22 +20,30 @@ class ProcessMedia implements ShouldQueue use Queueable; use SerializesModels; + /** @var string */ + protected $filename; + /** * Create a new job instance. + * + * @param string $filename */ - public function __construct( - protected string $filename - ) {} + public function __construct(string $filename) + { + $this->filename = $filename; + } /** * Execute the job. + * + * @param ImageManager $manager */ - public function handle(ImageManager $manager): void + public function handle(ImageManager $manager) { //open file try { - $image = $manager->read(storage_path('app') . '/' . $this->filename); - } catch (DecoderException) { + $image = $manager->make(storage_path('app') . '/' . $this->filename); + } catch (NotReadableException $exception) { // not an image; delete file and end job unlink(storage_path('app') . '/' . $this->filename); diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php index a5afd300..cd27563b 100644 --- a/app/Jobs/ProcessWebMention.php +++ b/app/Jobs/ProcessWebMention.php @@ -5,15 +5,13 @@ declare(strict_types=1); namespace App\Jobs; use App\Exceptions\RemoteContentNotFoundException; -use App\Models\Note; -use App\Models\WebMention; +use App\Models\{Note, WebMention}; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\SerializesModels; +use Illuminate\Queue\{InteractsWithQueue, SerializesModels}; use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException; use Jonnybarnes\WebmentionsParser\Parser; use Mf2; @@ -24,22 +22,34 @@ class ProcessWebMention implements ShouldQueue use Queueable; use SerializesModels; + /** @var Note */ + protected $note; + + /** @var string */ + protected $source; + /** * Create a new job instance. + * + * @param Note $note + * @param string $source */ - public function __construct( - protected Note $note, - protected string $source - ) {} + public function __construct(Note $note, string $source) + { + $this->note = $note; + $this->source = $source; + } /** * Execute the job. * + * @param Parser $parser + * @param Client $guzzle * @throws RemoteContentNotFoundException * @throws GuzzleException * @throws InvalidMentionException */ - public function handle(Parser $parser, Client $guzzle): void + public function handle(Parser $parser, Client $guzzle) { try { $response = $guzzle->request('GET', $this->source); @@ -52,8 +62,8 @@ class ProcessWebMention implements ShouldQueue foreach ($webmentions as $webmention) { // check webmention still references target // we try each type of mention (reply/like/repost) - if ($webmention->type === 'in-reply-to') { - if ($parser->checkInReplyTo($microformats, $this->note->longurl) === false) { + if ($webmention->type == 'in-reply-to') { + if ($parser->checkInReplyTo($microformats, $this->note->longurl) == false) { // it doesn’t so delete $webmention->delete(); @@ -66,16 +76,16 @@ class ProcessWebMention implements ShouldQueue return; } - if ($webmention->type === 'like-of') { - if ($parser->checkLikeOf($microformats, $this->note->longurl) === false) { + if ($webmention->type == 'like-of') { + if ($parser->checkLikeOf($microformats, $this->note->longurl) == false) { // it doesn’t so delete $webmention->delete(); return; } // note we don’t need to do anything if it still is a like } - if ($webmention->type === 'repost-of') { - if ($parser->checkRepostOf($microformats, $this->note->longurl) === false) { + if ($webmention->type == 'repost-of') { + if ($parser->checkRepostOf($microformats, $this->note->longurl) == false) { // it doesn’t so delete $webmention->delete(); @@ -91,7 +101,7 @@ class ProcessWebMention implements ShouldQueue $webmention->source = $this->source; $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(); @@ -99,23 +109,26 @@ class ProcessWebMention implements ShouldQueue /** * Save the HTML of a webmention for future use. + * + * @param string $html + * @param string $url */ - private function saveRemoteContent(string $html, string $url): void + private function saveRemoteContent($html, $url) { $filenameFromURL = str_replace( ['https://', 'http://'], ['https/', 'http/'], $url ); - if (str_ends_with($url, '/')) { + if (substr($url, -1) == '/') { $filenameFromURL .= 'index.html'; } $path = storage_path() . '/HTML/' . $filenameFromURL; $parts = explode('/', $path); $name = array_pop($parts); $dir = implode('/', $parts); - if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) { - throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir)); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); } file_put_contents("$dir/$name", $html); } diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php index d1b09776..cf2197e3 100644 --- a/app/Jobs/SaveProfileImage.php +++ b/app/Jobs/SaveProfileImage.php @@ -20,60 +20,55 @@ class SaveProfileImage implements ShouldQueue use Queueable; use SerializesModels; + protected array $microformats; + /** * Create a new job instance. + * + * @param array $microformats */ - public function __construct( - protected array $microformats - ) {} + public function __construct(array $microformats) + { + $this->microformats = $microformats; + } /** * Execute the job. + * + * @param Authorship $authorship */ - public function handle(Authorship $authorship): void + public function handle(Authorship $authorship) { try { $author = $authorship->findAuthor($this->microformats); - } catch (AuthorshipParserException) { + } catch (AuthorshipParserException $e) { return; } - $photo = Arr::get($author, 'properties.photo.0'); $home = Arr::get($author, 'properties.url.0'); - - if (is_array($photo) && array_key_exists('value', $photo)) { - $photo = $photo['value']; - } - - if (is_array($home)) { - $home = array_shift($home); - } - //dont save pbs.twimg.com links if ( $photo - && parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com' - && parse_url($photo, PHP_URL_HOST) !== 'twitter.com' + && parse_url($photo, PHP_URL_HOST) != 'pbs.twimg.com' + && parse_url($photo, PHP_URL_HOST) != 'twitter.com' ) { $client = resolve(Client::class); - try { $response = $client->get($photo); $image = $response->getBody(); - } catch (RequestException) { + } catch (RequestException $e) { // we are opening and reading the default image so that $default = public_path() . '/assets/profile-images/default-image'; $handle = fopen($default, 'rb'); $image = fread($handle, filesize($default)); fclose($handle); } - $path = public_path() . '/assets/profile-images/' . parse_url($home, PHP_URL_HOST) . '/image'; $parts = explode('/', $path); $name = array_pop($parts); $dir = implode('/', $parts); - if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) { - throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir)); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); } file_put_contents("$dir/$name", $image); } diff --git a/app/Jobs/SaveScreenshot.php b/app/Jobs/SaveScreenshot.php deleted file mode 100755 index 0e07efbd..00000000 --- a/app/Jobs/SaveScreenshot.php +++ /dev/null @@ -1,103 +0,0 @@ -request('POST', 'https://api.cloudconvert.com/v2/capture-website', [ - 'headers' => [ - 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'), - ], - 'json' => [ - 'url' => $this->bookmark->url, - 'output_format' => 'png', - 'screen_width' => 1440, - 'screen_height' => 900, - 'wait_until' => 'networkidle0', - 'wait_time' => 100, - ], - ]); - - $taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; - - // Now wait till the status job is finished - $screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $taskId, [ - 'headers' => [ - 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'), - ], - 'query' => [ - 'include' => 'payload', - ], - ]); - - $finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; - - // Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from - $exportImageJob = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [ - 'headers' => [ - 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'), - ], - 'json' => [ - 'input' => $finishedCaptureId, - 'archive_multiple_files' => false, - ], - ]); - - $exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id; - - // Again, wait till the status of this export job is finished - $finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $exportImageJobId, [ - 'headers' => [ - 'Authorization' => 'Bearer ' . config('services.cloudconvert.token'), - ], - 'query' => [ - 'include' => 'payload', - ], - ]); - - // Now we can download the screenshot and save it to the storage - $finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url; - - $finalImageUrlContent = $client->request('GET', $finalImageUrl); - - Storage::disk('public')->put('/assets/img/bookmarks/' . $taskId . '.png', $finalImageUrlContent->getBody()->getContents()); - - $this->bookmark->screenshot = $taskId; - $this->bookmark->save(); - } -} diff --git a/app/Jobs/SendWebMentions.php b/app/Jobs/SendWebMentions.php index 89babc89..2c566718 100644 --- a/app/Jobs/SendWebMentions.php +++ b/app/Jobs/SendWebMentions.php @@ -6,10 +6,7 @@ namespace App\Jobs; use App\Models\Note; use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; -use GuzzleHttp\Psr7\Header; -use GuzzleHttp\Psr7\UriResolver; -use GuzzleHttp\Psr7\Utils; +use GuzzleHttp\Psr7\Uri; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -22,23 +19,32 @@ class SendWebMentions implements ShouldQueue use Queueable; use SerializesModels; + /** @var Note */ + protected $note; + /** - * Create a new job instance. + * Create the job instance, inject dependencies. + * + * @param Note $note */ - public function __construct( - protected Note $note - ) {} + public function __construct(Note $note) + { + $this->note = $note; + } /** * Execute the job. * - * @throws GuzzleException + * @return void */ - public function handle(): void + public function handle() { - $urlsInReplyTo = explode(' ', $this->note->in_reply_to ?? ''); + //grab the URLs + $inReplyTo = $this->note->in_reply_to ?? ''; + // above so explode doesn’t complain about null being passed in + $urlsInReplyTo = explode(' ', $inReplyTo); $urlsNote = $this->getLinks($this->note->note); - $urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); + $urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); //filter out none URLs foreach ($urls as $url) { $endpoint = $this->discoverWebmentionEndpoint($url); if ($endpoint !== null) { @@ -56,16 +62,17 @@ class SendWebMentions implements ShouldQueue /** * Discover if a URL has a webmention endpoint. * - * @throws GuzzleException + * @param string $url + * @return string|null */ - public function discoverWebmentionEndpoint(string $url): ?string + public function discoverWebmentionEndpoint(string $url) { - // let’s not send webmentions to myself - if (parse_url($url, PHP_URL_HOST) === config('url.longurl')) { - return null; + //let’s not send webmentions to myself + if (parse_url($url, PHP_URL_HOST) == config('app.longurl')) { + return; } if (Str::startsWith($url, '/notes/tagged/')) { - return null; + return; } $endpoint = null; @@ -73,9 +80,9 @@ class SendWebMentions implements ShouldQueue $guzzle = resolve(Client::class); $response = $guzzle->get($url); //check HTTP Headers for webmention endpoint - $links = Header::parse($response->getHeader('Link')); + $links = \GuzzleHttp\Psr7\parse_header($response->getHeader('Link')); foreach ($links as $link) { - if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) { + if (mb_stristr($link['rel'], 'webmention')) { return $this->resolveUri(trim($link[0], '<>'), $url); } } @@ -90,20 +97,20 @@ class SendWebMentions implements ShouldQueue } elseif (array_key_exists('http://webmention.org/', $rels[0])) { $endpoint = $rels[0]['http://webmention.org/'][0]; } - - if ($endpoint === null) { - return null; + if ($endpoint) { + return $this->resolveUri($endpoint, $url); } - - return $this->resolveUri($endpoint, $url); } /** * Get the URLs from a note. + * + * @param string|null $html + * @return array */ public function getLinks(?string $html): array { - if ($html === '' || is_null($html)) { + if ($html == '' || is_null($html)) { return []; } @@ -120,16 +127,22 @@ class SendWebMentions implements ShouldQueue /** * Resolve a URI if necessary. + * + * @todo Update deprecated resolve method + * + * @param string $url + * @param string $base The base of the URL + * @return string */ public function resolveUri(string $url, string $base): string { - $endpoint = Utils::uriFor($url); - if ($endpoint->getScheme() !== '') { + $endpoint = \GuzzleHttp\Psr7\uri_for($url); + if ($endpoint->getScheme() != '') { return (string) $endpoint; } - return (string) UriResolver::resolve( - Utils::uriFor($base), + return (string) Uri::resolve( + \GuzzleHttp\Psr7\uri_for($base), $endpoint ); } diff --git a/app/Jobs/SyndicateBookmarkToTwitter.php b/app/Jobs/SyndicateBookmarkToTwitter.php new file mode 100644 index 00000000..6eb40ab7 --- /dev/null +++ b/app/Jobs/SyndicateBookmarkToTwitter.php @@ -0,0 +1,65 @@ +bookmark = $bookmark; + } + + /** + * Execute the job. + * + * @param Client $guzzle + * @throws GuzzleException + */ + public function handle(Client $guzzle) + { + //send webmention + $response = $guzzle->request( + 'POST', + 'https://brid.gy/publish/webmention', + [ + 'form_params' => [ + 'source' => $this->bookmark->longurl, + 'target' => 'https://brid.gy/publish/twitter', + 'bridgy_omit_link' => 'maybe', + ], + ] + ); + //parse for syndication URL + if ($response->getStatusCode() == 201) { + $json = json_decode((string) $response->getBody()); + $syndicates = $this->bookmark->syndicates; + $syndicates['twitter'] = $json->url; + $this->bookmark->syndicates = $syndicates; + $this->bookmark->save(); + } + } +} diff --git a/app/Jobs/SyndicateNoteToBluesky.php b/app/Jobs/SyndicateNoteToBluesky.php deleted file mode 100644 index e815be34..00000000 --- a/app/Jobs/SyndicateNoteToBluesky.php +++ /dev/null @@ -1,62 +0,0 @@ -request( - 'POST', - 'https://brid.gy/micropub', - [ - 'headers' => [ - 'Authorization' => 'Bearer ' . config('bridgy.bluesky_token'), - ], - 'json' => [ - 'type' => ['h-entry'], - 'properties' => [ - 'content' => [$this->note->getRawOriginal('note')], - ], - ], - ] - ); - - // Parse for syndication URL - if ($response->getStatusCode() === 201) { - $this->note->bluesky_url = $response->getHeader('Location')[0]; - $this->note->save(); - } - } -} diff --git a/app/Jobs/SyndicateNoteToMastodon.php b/app/Jobs/SyndicateNoteToMastodon.php deleted file mode 100644 index b79c092c..00000000 --- a/app/Jobs/SyndicateNoteToMastodon.php +++ /dev/null @@ -1,63 +0,0 @@ -request( - 'POST', - 'https://brid.gy/micropub', - [ - 'headers' => [ - 'Authorization' => 'Bearer ' . config('bridgy.mastodon_token'), - ], - 'json' => [ - 'type' => ['h-entry'], - 'properties' => [ - 'content' => [$this->note->getRawOriginal('note')], - ], - ], - ] - ); - - // Parse for syndication URL - if ($response->getStatusCode() === 201) { - $mastodonUrl = $response->getHeader('Location')[0]; - $this->note->mastodon_url = $mastodonUrl; - $this->note->save(); - } - } -} diff --git a/app/Jobs/SyndicateNoteToTwitter.php b/app/Jobs/SyndicateNoteToTwitter.php new file mode 100644 index 00000000..4ac64a07 --- /dev/null +++ b/app/Jobs/SyndicateNoteToTwitter.php @@ -0,0 +1,62 @@ +note = $note; + } + + /** + * Execute the job. + * + * @param Client $guzzle + * @throws GuzzleException + */ + public function handle(Client $guzzle) + { + //send webmention + $response = $guzzle->request( + 'POST', + 'https://brid.gy/publish/webmention', + [ + 'form_params' => [ + 'source' => $this->note->longurl, + 'target' => 'https://brid.gy/publish/twitter', + 'bridgy_omit_link' => 'maybe', + ], + ] + ); + //parse for syndication URL + if ($response->getStatusCode() == 201) { + $json = json_decode((string) $response->getBody()); + $tweet_id = basename(parse_url($json->url, PHP_URL_PATH)); + $this->note->tweet_id = $tweet_id; + $this->note->save(); + } + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index 42895f8d..a48c1ed9 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -5,45 +5,80 @@ declare(strict_types=1); namespace App\Models; use Cviebrock\EloquentSluggable\Sluggable; +use Eloquent; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; -use League\CommonMark\Environment\Environment; -use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; -use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; -use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode; -use League\CommonMark\MarkdownConverter; +use Illuminate\Support\Carbon; +use League\CommonMark\Block\Element\FencedCode; +use League\CommonMark\Block\Element\IndentedCode; +use League\CommonMark\CommonMarkConverter; +use League\CommonMark\Environment; use Spatie\CommonMarkHighlighter\FencedCodeRenderer; use Spatie\CommonMarkHighlighter\IndentedCodeRenderer; +/** + * App\Models\Article. + * + * @property int $id + * @property string $titleurl + * @property string|null $url + * @property string $title + * @property string $main + * @property int $published + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property Carbon|null $deleted_at + * @property-read string $html + * @property-read string $human_time + * @property-read string $link + * @property-read string $pubdate + * @property-read string $tooltip_time + * @property-read string $w3c_time + * @method static Builder|Article date($year = null, $month = null) + * @method static Builder|Article findSimilarSlugs($attribute, $config, $slug) + * @method static bool|null forceDelete() + * @method static Builder|Article newModelQuery() + * @method static Builder|Article newQuery() + * @method static \Illuminate\Database\Query\Builder|Article onlyTrashed() + * @method static Builder|Article query() + * @method static bool|null restore() + * @method static Builder|Article whereCreatedAt($value) + * @method static Builder|Article whereDeletedAt($value) + * @method static Builder|Article whereId($value) + * @method static Builder|Article whereMain($value) + * @method static Builder|Article wherePublished($value) + * @method static Builder|Article whereTitle($value) + * @method static Builder|Article whereTitleurl($value) + * @method static Builder|Article whereUpdatedAt($value) + * @method static Builder|Article whereUrl($value) + * @method static \Illuminate\Database\Query\Builder|Article withTrashed() + * @method static \Illuminate\Database\Query\Builder|Article withoutTrashed() + * @mixin Eloquent + */ class Article extends Model { - use HasFactory; use Sluggable; use SoftDeletes; - /** @var string */ + /** + * The attributes that should be mutated to dates. + * + * @var array + */ + protected $dates = ['created_at', 'updated_at', 'deleted_at']; + + /** + * The database table used by the model. + * + * @var string + */ protected $table = 'articles'; - /** @var array */ - protected $fillable = [ - 'url', - 'title', - 'main', - 'published', - ]; - - /** @var array */ - protected $casts = [ - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'deleted_at' => 'datetime', - ]; - /** * Return the sluggable configuration array for this model. + * + * @return array */ public function sluggable(): array { @@ -54,62 +89,89 @@ class Article extends Model ]; } - protected function html(): Attribute - { - return Attribute::get( - get: function () { - $environment = new Environment(); - $environment->addExtension(new CommonMarkCoreExtension()); - $environment->addRenderer(FencedCode::class, new FencedCodeRenderer()); - $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer()); - $markdownConverter = new MarkdownConverter($environment); + /** + * We shall set a blacklist of non-modifiable model attributes. + * + * @var array + */ + protected $guarded = ['id']; - return $markdownConverter->convert($this->main)->getContent(); - }, - ); + /** + * Process the article for display. + * + * @return string + */ + public function getHtmlAttribute(): string + { + $environment = Environment::createCommonMarkEnvironment(); + $environment->addBlockRenderer(FencedCode::class, new FencedCodeRenderer()); + $environment->addBlockRenderer(IndentedCode::class, new IndentedCodeRenderer()); + $commonMarkConverter = new CommonMarkConverter([], $environment); + + return $commonMarkConverter->convertToHtml($this->main); } - protected function w3cTime(): Attribute + /** + * Convert updated_at to W3C time format. + * + * @return string + */ + public function getW3cTimeAttribute(): string { - return Attribute::get( - get: fn () => $this->updated_at->toW3CString(), - ); + return $this->updated_at->toW3CString(); } - protected function tooltipTime(): Attribute + /** + * Convert updated_at to a tooltip appropriate format. + * + * @return string + */ + public function getTooltipTimeAttribute(): string { - return Attribute::get( - get: fn () => $this->updated_at->toRFC850String(), - ); + return $this->updated_at->toRFC850String(); } - protected function humanTime(): Attribute + /** + * Convert updated_at to a human readable format. + * + * @return string + */ + public function getHumanTimeAttribute(): string { - return Attribute::get( - get: fn () => $this->updated_at->diffForHumans(), - ); + return $this->updated_at->diffForHumans(); } - protected function pubdate(): Attribute + /** + * Get the pubdate value for RSS feeds. + * + * @return string + */ + public function getPubdateAttribute(): string { - return Attribute::get( - get: fn () => $this->updated_at->toRSSString(), - ); + return $this->updated_at->toRSSString(); } - protected function link(): Attribute + /** + * A link to the article, i.e. `/blog/1999/12/25/merry-christmas`. + * + * @return string + */ + public function getLinkAttribute(): string { - return Attribute::get( - get: fn () => '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl, - ); + return '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl; } /** * Scope a query to only include articles from a particular year/month. + * + * @param Builder $query + * @param int|null $year + * @param int|null $month + * @return Builder */ - 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) { + if ($year == null) { return $query; } $start = $year . '-01-01 00:00:00'; diff --git a/app/Models/Bio.php b/app/Models/Bio.php deleted file mode 100644 index b9a0e78b..00000000 --- a/app/Models/Bio.php +++ /dev/null @@ -1,11 +0,0 @@ - */ + /** + * The attributes that are mass assignable. + * + * @var array + */ protected $fillable = ['url', 'name', 'content']; - /** @var array */ + /** + * The attributes that should be cast to native types. + * + * @var array + */ protected $casts = [ 'syndicates' => 'array', ]; - public function tags(): BelongsToMany + /** + * The tags that belong to the bookmark. + * + * @return BelongsToMany + */ + public function tags() { return $this->belongsToMany('App\Models\Tag'); } - protected function longurl(): Attribute + /** + * The full url of a bookmark. + * + * @return string + */ + public function getLongurlAttribute(): string { - return Attribute::get( - get: fn () => config('app.url') . '/bookmarks/' . $this->id, - ); + return config('app.url') . '/bookmarks/' . $this->id; } } diff --git a/app/Models/Contact.php b/app/Models/Contact.php index 6f193f41..a15a4fc2 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -4,33 +4,48 @@ declare(strict_types=1); namespace App\Models; -use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Factories\HasFactory; +use Eloquent; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; +/** + * App\Models\Contact. + * + * @property int $id + * @property string $nick + * @property string $name + * @property string|null $homepage + * @property string|null $twitter + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property string|null $facebook + * @method static Builder|Contact newModelQuery() + * @method static Builder|Contact newQuery() + * @method static Builder|Contact query() + * @method static Builder|Contact whereCreatedAt($value) + * @method static Builder|Contact whereFacebook($value) + * @method static Builder|Contact whereHomepage($value) + * @method static Builder|Contact whereId($value) + * @method static Builder|Contact whereName($value) + * @method static Builder|Contact whereNick($value) + * @method static Builder|Contact whereTwitter($value) + * @method static Builder|Contact whereUpdatedAt($value) + * @mixin Eloquent + */ class Contact extends Model { - use HasFactory; - - /** @var string */ + /** + * The database table used by the model. + * + * @var string + */ protected $table = 'contacts'; - /** @var array */ + /** + * We shall guard against mass-migration. + * + * @var array + */ protected $fillable = ['nick', 'name', 'homepage', 'twitter', 'facebook']; - - protected function photo(): Attribute - { - $photo = '/assets/profile-images/default-image'; - - if (array_key_exists('homepage', $this->attributes) && ! empty($this->attributes['homepage'])) { - $host = parse_url($this->attributes['homepage'], PHP_URL_HOST); - if (file_exists(public_path() . '/assets/profile-images/' . $host . '/image')) { - $photo = '/assets/profile-images/' . $host . '/image'; - } - } - - return Attribute::make( - get: fn () => $photo, - ); - } } diff --git a/app/Models/Like.php b/app/Models/Like.php index f9ac3bcb..93d9750d 100644 --- a/app/Models/Like.php +++ b/app/Models/Like.php @@ -5,52 +5,83 @@ declare(strict_types=1); namespace App\Models; use App\Traits\FilterHtml; -use Illuminate\Database\Eloquent\Casts\Attribute; +use Eloquent; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; use Mf2; +/** + * App\Models\Like. + * + * @property int $id + * @property string $url + * @property string|null $author_name + * @property string|null $author_url + * @property string|null $content + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @method static Builder|Like newModelQuery() + * @method static Builder|Like newQuery() + * @method static Builder|Like query() + * @method static Builder|Like whereAuthorName($value) + * @method static Builder|Like whereAuthorUrl($value) + * @method static Builder|Like whereContent($value) + * @method static Builder|Like whereCreatedAt($value) + * @method static Builder|Like whereId($value) + * @method static Builder|Like whereUpdatedAt($value) + * @method static Builder|Like whereUrl($value) + * @mixin Eloquent + */ class Like extends Model { use FilterHtml; use HasFactory; - /** @var array */ protected $fillable = ['url']; - protected function url(): Attribute + /** + * Normalize the URL of a Like. + * + * @param string $value The provided URL + */ + public function setUrlAttribute(string $value) { - return Attribute::set( - set: fn ($value) => normalize_url($value), - ); + $this->attributes['url'] = normalize_url($value); } - protected function authorUrl(): Attribute + /** + * Normalize the URL of the author of the like. + * + * @param string|null $value The author’s url + */ + public function setAuthorUrlAttribute(?string $value) { - return Attribute::set( - set: fn ($value) => normalize_url($value), - ); + $this->attributes['author_url'] = normalize_url($value); } - protected function content(): Attribute + /** + * If the content contains HTML, filter it. + * + * @param string|null $value The content of the like + * @return string|null + */ + public function getContentAttribute(?string $value): ?string { - return Attribute::get( - get: function ($value, $attributes) { - if ($value === null) { - return null; - } + if ($value === null) { + return null; + } - $mf2 = Mf2\parse($value, $attributes['url']); + $mf2 = Mf2\parse($value, $this->url); - if (Arr::get($mf2, 'items.0.properties.content.0.html')) { - return $this->filterHtml( - $mf2['items'][0]['properties']['content'][0]['html'] - ); - } + if (Arr::get($mf2, 'items.0.properties.content.0.html')) { + return $this->filterHtml( + $mf2['items'][0]['properties']['content'][0]['html'] + ); + } - return $value; - } - ); + return $value; } } diff --git a/app/Models/Media.php b/app/Models/Media.php index c4dd6d5c..df8a4666 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -4,96 +4,132 @@ declare(strict_types=1); namespace App\Models; -use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Factories\HasFactory; +use Eloquent; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; use Illuminate\Support\Str; +/** + * App\Models\Media. + * + * @property int $id + * @property string|null $token + * @property string $path + * @property string $type + * @property int|null $note_id + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property string|null $image_widths + * @property-read string $mediumurl + * @property-read string $smallurl + * @property-read string $url + * @property-read Note|null $note + * @method static Builder|Media newModelQuery() + * @method static Builder|Media newQuery() + * @method static Builder|Media query() + * @method static Builder|Media whereCreatedAt($value) + * @method static Builder|Media whereId($value) + * @method static Builder|Media whereImageWidths($value) + * @method static Builder|Media whereNoteId($value) + * @method static Builder|Media wherePath($value) + * @method static Builder|Media whereToken($value) + * @method static Builder|Media whereType($value) + * @method static Builder|Media whereUpdatedAt($value) + * @mixin Eloquent + */ class Media extends Model { - use HasFactory; - - /** @var string */ + /** + * The table associated with the model. + * + * @var string + */ protected $table = 'media_endpoint'; - /** @var array */ + /** + * The attributes that are mass assignable. + * + * @var array + */ protected $fillable = ['token', 'path', 'type', 'image_widths']; + /** + * Get the note that owns this media. + * + * @return BelongsTo + */ public function note(): BelongsTo { - return $this->belongsTo(Note::class); + return $this->belongsTo('App\Models\Note'); } - protected function url(): Attribute + /** + * Get the URL for an S3 media file. + * + * @return string + */ + public function getUrlAttribute(): string { - return Attribute::get( - get: function ($value, $attributes) { - if (Str::startsWith($attributes['path'], 'https://')) { - return $attributes['path']; - } + if (Str::startsWith($this->path, 'https://')) { + return $this->path; + } - return config('filesystems.disks.s3.url') . '/' . $attributes['path']; - } - ); + return config('filesystems.disks.s3.url') . '/' . $this->path; } - protected function mediumurl(): Attribute + /** + * Get the URL for the medium size of an S3 image file. + * + * @return string + */ + public function getMediumurlAttribute(): string { - return Attribute::get( - get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'medium'), - ); + $basename = $this->getBasename($this->path); + $extension = $this->getExtension($this->path); + + return config('filesystems.disks.s3.url') . '/' . $basename . '-medium.' . $extension; } - protected function smallurl(): Attribute + /** + * Get the URL for the small size of an S3 image file. + * + * @return string + */ + public function getSmallurlAttribute(): string { - return Attribute::get( - get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'small'), - ); + $basename = $this->getBasename($this->path); + $extension = $this->getExtension($this->path); + + return config('filesystems.disks.s3.url') . '/' . $basename . '-small.' . $extension; } - protected function mimetype(): Attribute - { - return Attribute::get( - get: function ($value, $attributes) { - $extension = $this->getExtension($attributes['path']); - - return match ($extension) { - 'gif' => 'image/gif', - 'jpeg', 'jpg' => 'image/jpeg', - 'png' => 'image/png', - 'svg' => 'image/svg+xml', - 'tiff' => 'image/tiff', - 'webp' => 'image/webp', - 'mp4' => 'video/mp4', - 'mkv' => 'video/mkv', - default => 'application/octet-stream', - }; - }, - ); - } - - private function getSizeUrl(string $path, string $size): string - { - $basename = $this->getBasename($path); - $extension = $this->getExtension($path); - - return config('filesystems.disks.s3.url') . '/' . $basename . '-' . $size . '.' . $extension; - } - - private function getBasename(string $path): string + /** + * Give the real part of a filename, i.e. strip the file extension. + * + * @param string $path + * @return string + */ + public function getBasename(string $path): string { // the following achieves this data flow // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar $filenameParts = explode('.', $path); array_pop($filenameParts); - return ltrim(array_reduce($filenameParts, static function ($carry, $item) { + return ltrim(array_reduce($filenameParts, function ($carry, $item) { return $carry . '.' . $item; }, ''), '.'); } - private function getExtension(string $path): string + /** + * Get the extension from a given filename. + * + * @param string $path + * @return string + */ + public function getExtension(string $path): string { $parts = explode('.', $path); diff --git a/app/Models/MicropubClient.php b/app/Models/MicropubClient.php index 669c7284..e5cc4900 100644 --- a/app/Models/MicropubClient.php +++ b/app/Models/MicropubClient.php @@ -4,20 +4,57 @@ declare(strict_types=1); namespace App\Models; +use Eloquent; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; +/** + * App\Models\MicropubClient. + * + * @property int $id + * @property string $client_url + * @property string $client_name + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read Collection|\App\Models\Note[] $notes + * @property-read int|null $notes_count + * @method static Builder|MicropubClient newModelQuery() + * @method static Builder|MicropubClient newQuery() + * @method static Builder|MicropubClient query() + * @method static Builder|MicropubClient whereClientName($value) + * @method static Builder|MicropubClient whereClientUrl($value) + * @method static Builder|MicropubClient whereCreatedAt($value) + * @method static Builder|MicropubClient whereId($value) + * @method static Builder|MicropubClient whereUpdatedAt($value) + * @mixin Eloquent + */ class MicropubClient extends Model { use HasFactory; - /** @var string */ + /** + * The table associated with the model. + * + * @var string + */ protected $table = 'clients'; - /** @var array */ + /** + * The attributes that are mass assignable. + * + * @var array + */ protected $fillable = ['client_url', 'client_name']; + /** + * Define the relationship with notes. + * + * @return HasMany + */ public function notes(): HasMany { return $this->hasMany('App\Models\Note', 'client_id', 'client_url'); diff --git a/app/Models/Note.php b/app/Models/Note.php index f854b598..d86b8eac 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -4,34 +4,88 @@ declare(strict_types=1); namespace App\Models; -use App\CommonMark\Generators\MentionGenerator; -use App\CommonMark\Renderers\MentionRenderer; +use App\Exceptions\TwitterContentException; use Codebird\Codebird; +use Eloquent; use Exception; use GuzzleHttp\Client; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\MorphMany; -use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Eloquent\Relations\{BelongsTo, BelongsToMany, HasMany, MorphMany}; +use Illuminate\Database\Eloquent\{Builder, Collection, Factories\HasFactory, Model, SoftDeletes}; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Jonnybarnes\IndieWeb\Numbers; use Laravel\Scout\Searchable; -use League\CommonMark\Environment\Environment; +use League\CommonMark\Block\Element\{FencedCode, IndentedCode}; use League\CommonMark\Extension\Autolink\AutolinkExtension; -use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; -use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; -use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode; -use League\CommonMark\Extension\Mention\Mention; -use League\CommonMark\Extension\Mention\MentionExtension; -use League\CommonMark\MarkdownConverter; +use League\CommonMark\{CommonMarkConverter, Environment}; use Normalizer; -use Spatie\CommonMarkHighlighter\FencedCodeRenderer; -use Spatie\CommonMarkHighlighter\IndentedCodeRenderer; +use Spatie\CommonMarkHighlighter\{FencedCodeRenderer, IndentedCodeRenderer}; +/** + * App\Models\Note. + * + * @property int $id + * @property string|null $note + * @property string|null $in_reply_to + * @property string $shorturl + * @property string|null $location + * @property int|null $photo + * @property string|null $tweet_id + * @property string|null $client_id + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property Carbon|null $deleted_at + * @property int|null $place_id + * @property string|null $facebook_url + * @property string|null $searchable + * @property string|null $swarm_url + * @property string|null $instagram_url + * @property-read MicropubClient|null $client + * @property-read string|null $address + * @property-read string $content + * @property-read string $humandiff + * @property-read string $iso8601 + * @property-read float|null $latitude + * @property-read float|null $longitude + * @property-read string $longurl + * @property-read string $nb60id + * @property-read string $pubdate + * @property-read object|null $twitter + * @property-read string $twitter_content + * @property-read Collection|Media[] $media + * @property-read int|null $media_count + * @property-read Place|null $place + * @property-read Collection|Tag[] $tags + * @property-read int|null $tags_count + * @property-read Collection|WebMention[] $webmentions + * @property-read int|null $webmentions_count + * @method static bool|null forceDelete() + * @method static Builder|Note nb60($nb60id) + * @method static Builder|Note newModelQuery() + * @method static Builder|Note newQuery() + * @method static \Illuminate\Database\Query\Builder|Note onlyTrashed() + * @method static Builder|Note query() + * @method static bool|null restore() + * @method static Builder|Note whereClientId($value) + * @method static Builder|Note whereCreatedAt($value) + * @method static Builder|Note whereDeletedAt($value) + * @method static Builder|Note whereFacebookUrl($value) + * @method static Builder|Note whereId($value) + * @method static Builder|Note whereInReplyTo($value) + * @method static Builder|Note whereInstagramUrl($value) + * @method static Builder|Note whereLocation($value) + * @method static Builder|Note whereNote($value) + * @method static Builder|Note wherePhoto($value) + * @method static Builder|Note wherePlaceId($value) + * @method static Builder|Note whereSearchable($value) + * @method static Builder|Note whereShorturl($value) + * @method static Builder|Note whereSwarmUrl($value) + * @method static Builder|Note whereTweetId($value) + * @method static Builder|Note whereUpdatedAt($value) + * @method static \Illuminate\Database\Query\Builder|Note withTrashed() + * @method static \Illuminate\Database\Query\Builder|Note withoutTrashed() + * @mixin Eloquent + */ class Note extends Model { use HasFactory; @@ -48,10 +102,12 @@ class Note extends Model /** * This variable is used to keep track of contacts in a note. */ - protected ?array $contacts; + protected $contacts; /** * Set our contacts variable to null. + * + * @param array $attributes */ public function __construct(array $attributes = []) { @@ -59,46 +115,85 @@ class Note extends Model $this->contacts = null; } - /** @var string */ + /** + * The database table used by the model. + * + * @var string + */ protected $table = 'notes'; - /** @var array */ + /** + * Mass-assignment. + * + * @var array + */ protected $fillable = [ 'note', 'in_reply_to', 'client_id', ]; - /** @var array */ + /** + * Hide the column used with Laravel Scout. + * + * @var array + */ protected $hidden = ['searchable']; - public function tags(): BelongsToMany + /** + * Define the relationship with tags. + * + * @return BelongsToMany + */ + public function tags() { - return $this->belongsToMany(Tag::class); - } - - public function client(): BelongsTo - { - return $this->belongsTo(MicropubClient::class, 'client_id', 'client_url'); - } - - public function webmentions(): MorphMany - { - return $this->morphMany(WebMention::class, 'commentable'); - } - - public function place(): BelongsTo - { - return $this->belongsTo(Place::class); - } - - public function media(): HasMany - { - return $this->hasMany(Media::class); + return $this->belongsToMany('App\Models\Tag'); } /** - * @return array + * Define the relationship with clients. + * + * @return BelongsTo + */ + public function client() + { + return $this->belongsTo('App\Models\MicropubClient', 'client_id', 'client_url'); + } + + /** + * Define the relationship with webmentions. + * + * @return MorphMany + */ + public function webmentions() + { + return $this->morphMany('App\Models\WebMention', 'commentable'); + } + + /** + * Define the relationship with places. + * + * @return BelongsTo + */ + public function place() + { + return $this->belongsTo('App\Models\Place'); + } + + /** + * Define the relationship with media. + * + * @return HasMany + */ + public function media() + { + return $this->hasMany('App\Models\Media'); + } + + /** + * Set the attributes to be indexed for searching with Scout. + * + * @return array */ public function toSearchableArray(): array { @@ -107,7 +202,12 @@ class Note extends Model ]; } - public function setNoteAttribute(?string $value): void + /** + * Normalize the note to Unicode FORM C. + * + * @param string|null $value + */ + public function setNoteAttribute(?string $value) { if ($value !== null) { $normalized = normalizer_normalize($value, Normalizer::FORM_C); @@ -120,6 +220,9 @@ class Note extends Model /** * Pre-process notes for web-view. + * + * @param string|null $value + * @return string|null */ public function getNoteAttribute(?string $value): ?string { @@ -132,7 +235,8 @@ class Note extends Model return null; } - $hashtags = $this->autoLinkHashtag($value); + $hcards = $this->makeHCards($value); + $hashtags = $this->autoLinkHashtag($hcards); return $this->convertMarkdown($hashtags); } @@ -140,21 +244,23 @@ class Note extends Model /** * Provide the content_html for JSON feed. * - * In particular, we want to include media links such as images. + * In particular we want to include media links such as images. + * + * @return string */ public function getContentAttribute(): string { - $note = $this->getRawOriginal('note'); + $note = $this->note; foreach ($this->media as $media) { - if ($media->type === 'image') { - $note .= PHP_EOL . ''; + if ($media->type == 'image') { + $note .= ''; } - if ($media->type === 'audio') { - $note .= PHP_EOL . '