Merge pull request 'MTM No snow' (#49) from develop into main

Reviewed-on: #49
This commit is contained in:
Jonny Barnes 2025-07-01 17:57:22 +02:00
commit 0ef822de0c
208 changed files with 3138 additions and 3261 deletions

View file

@ -1,14 +0,0 @@
APP_ENV=testing
APP_DEBUG=true
APP_KEY=base64:6DJhvZLVjE6dD4Cqrteh+6Z5vZlG+v/soCKcDHLOAH0=
APP_URL=http://localhost:8000
APP_LONGURL=localhost
APP_SHORTURL=local
DB_CONNECTION=travis
CACHE_DRIVER=array
SESSION_DRIVER=file
QUEUE_DRIVER=sync
SCOUT_DRIVER=pgsql

View file

@ -4,8 +4,6 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
APP_URL=https://example.com APP_URL=https://example.com
APP_LONGURL=example.com
APP_SHORTURL=examp.le
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en

View file

@ -1,70 +0,0 @@
APP_NAME=Laravel
APP_ENV=testing
APP_KEY=SomeRandomString # Leave this
APP_DEBUG=false
APP_LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=jbukdev_testing
DB_USERNAME=postgres
DB_PASSWORD=postgres
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
AWS_S3_KEY=your-key
AWS_S3_SECRET=your-secret
AWS_S3_REGION=region
AWS_S3_BUCKET=your-bucket
AWS_S3_URL=https://xxxxxxx.s3-region.amazonaws.com
APP_URL=https://example.com # This one is necessary
APP_LONGURL=example.com
APP_SHORTURL=examp.le
ADMIN_USER=admin # pick something better, this is used for `/admin`
ADMIN_PASS=password
DISPLAY_NAME="Joe Bloggs" # This is used for example in the header and titles
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_ACCESS_TOKEN=
TWITTER_ACCESS_TOKEN_SECRET=
SCOUT_DRIVER=database
SCOUT_QUEUE=false
PIWIK=false
FATHOM_ID=
APP_TIMEZONE=UTC
APP_LANG=en
APP_LOG=daily
SECURE_SESSION_COOKIE=true
LOG_SLACK_WEBHOOK_URL=
FLARE_KEY=
FONT_LINK=
BRIDGY_MASTODON_TOKEN=

4
.gitattributes vendored
View file

@ -5,7 +5,3 @@
*.html diff=html *.html diff=html
*.md diff=markdown *.md diff=markdown
*.php diff=php *.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

View file

@ -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"

View file

@ -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 {}

View file

@ -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

View file

@ -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

1
.gitignore vendored
View file

@ -4,7 +4,6 @@
/public/coverage /public/coverage
/public/hot /public/hot
/public/files /public/files
/public/fonts
/public/storage /public/storage
/storage/*.key /storage/*.key
/vendor /vendor

View file

@ -1,5 +0,0 @@
{
"$schema": "/Users/jonny/git/phpactor/phpactor.schema.json",
"language_server_phpstan.enabled": false,
"language_server_psalm.enabled": true
}

View file

@ -1,9 +0,0 @@
php:
preset: laravel
disabled:
- no_unused_imports
finder:
not-name:
- index.php
js: true
css: true

View file

@ -8,8 +8,6 @@ use Illuminate\Support\Facades\DB;
/** /**
* @codeCoverageIgnore * @codeCoverageIgnore
*
* @psalm-suppress UnusedClass
*/ */
class MigratePlaceDataFromPostgis extends Command class MigratePlaceDataFromPostgis extends Command
{ {

View file

@ -9,9 +9,6 @@ use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\FileSystem\FileSystem; use Illuminate\FileSystem\FileSystem;
/**
* @psalm-suppress UnusedClass
*/
class ParseCachedWebMentions extends Command class ParseCachedWebMentions extends Command
{ {
/** /**

View file

@ -8,9 +8,6 @@ use App\Jobs\DownloadWebMention;
use App\Models\WebMention; use App\Models\WebMention;
use Illuminate\Console\Command; use Illuminate\Console\Command;
/**
* @psalm-suppress UnusedClass
*/
class ReDownloadWebMentions extends Command class ReDownloadWebMentions extends Command
{ {
/** /**

View file

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
class InvalidTokenScopeException extends \Exception {}

View file

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
class MicropubHandlerException extends \Exception {}

View file

@ -9,9 +9,6 @@ use App\Models\Article;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class ArticlesController extends Controller class ArticlesController extends Controller
{ {
public function index(): View public function index(): View

View file

@ -10,9 +10,6 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class BioController extends Controller class BioController extends Controller
{ {
public function show(): View public function show(): View

View file

@ -9,9 +9,6 @@ use App\Models\MicropubClient;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class ClientsController extends Controller class ClientsController extends Controller
{ {
/** /**

View file

@ -12,9 +12,6 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class ContactsController extends Controller class ContactsController extends Controller
{ {
/** /**

View file

@ -7,9 +7,6 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class HomeController extends Controller class HomeController extends Controller
{ {
/** /**

View file

@ -10,9 +10,6 @@ use App\Models\Like;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class LikesController extends Controller class LikesController extends Controller
{ {
/** /**

View file

@ -11,9 +11,6 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class NotesController extends Controller class NotesController extends Controller
{ {
/** /**

View file

@ -18,6 +18,7 @@ use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\View\View; use Illuminate\View\View;
use ParagonIE\ConstantTime\Base64UrlSafe; use ParagonIE\ConstantTime\Base64UrlSafe;
use Random\RandomException;
use Throwable; use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport; use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
@ -38,9 +39,6 @@ use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity; use Webauthn\PublicKeyCredentialUserEntity;
/**
* @psalm-suppress UnusedClass
*/
class PasskeysController extends Controller class PasskeysController extends Controller
{ {
public function index(): View public function index(): View
@ -52,22 +50,26 @@ class PasskeysController extends Controller
return view('admin.passkeys.index', compact('passkeys')); return view('admin.passkeys.index', compact('passkeys'));
} }
public function getCreateOptions(): JsonResponse /**
* @throws RandomException
* @throws \JsonException
*/
public function getCreateOptions(Request $request): JsonResponse
{ {
/** @var User $user */ /** @var User $user */
$user = auth()->user(); $user = auth()->user();
// RP Entity i.e. the application // RP Entity i.e. the application
$rpEntity = PublicKeyCredentialRpEntity::create( $rpEntity = PublicKeyCredentialRpEntity::create(
config('app.name'), name: config('app.name'),
config('url.longurl'), id: config('app.url'),
); );
// User Entity // User Entity
$userEntity = PublicKeyCredentialUserEntity::create( $userEntity = PublicKeyCredentialUserEntity::create(
$user->name, name: $user->name,
(string) $user->id, id: (string) $user->id,
$user->name, displayName: $user->name,
); );
// Challenge // Challenge
@ -85,25 +87,38 @@ class PasskeysController extends Controller
$authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create( $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create(
userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED, userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED, residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED,
requireResidentKey: true,
); );
$options = PublicKeyCredentialCreationOptions::create( $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::create(
$rpEntity, rp: $rpEntity,
$userEntity, user: $userEntity,
$challenge, challenge: $challenge,
$pubKeyCredParams, pubKeyCredParams: $pubKeyCredParams,
authenticatorSelection: $authenticatorSelectionCriteria, authenticatorSelection: $authenticatorSelectionCriteria,
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE
); );
$options = json_encode($options, JSON_THROW_ON_ERROR); $attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$webauthnSerializerFactory = new WebauthnSerializerFactory(
attestationStatementSupportManager: $attestationStatementSupportManager
);
$webauthnSerializer = $webauthnSerializerFactory->create();
$publicKeyCredentialCreationOptions = $webauthnSerializer->serialize(
data: $publicKeyCredentialCreationOptions,
format: 'json'
);
session(['create_options' => $options]); $request->session()->put('create_options', $publicKeyCredentialCreationOptions);
return JsonResponse::fromJsonString($options); return JsonResponse::fromJsonString($publicKeyCredentialCreationOptions);
} }
/**
* @throws Throwable
* @throws WebauthnException
* @throws \JsonException
*/
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
/** @var User $user */ /** @var User $user */
@ -111,17 +126,17 @@ class PasskeysController extends Controller
$publicKeyCredentialCreationOptionsData = session('create_options'); $publicKeyCredentialCreationOptionsData = session('create_options');
// Unset session data to mitigate replay attacks // Unset session data to mitigate replay attacks
session()->forget('create_options'); $request->session()->forget('create_options');
if (empty($publicKeyCredentialCreationOptionsData)) { if (empty($publicKeyCredentialCreationOptionsData)) {
throw new WebAuthnException('No public key credential request options found'); throw new WebAuthnException('No public key credential request options found');
} }
$attestationStatementSupportManager = new AttestationStatementSupportManager; $attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport); $attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$webauthnSerializerFactory = new WebauthnSerializerFactory(
$webauthnSerializer = (new WebauthnSerializerFactory( attestationStatementSupportManager: $attestationStatementSupportManager
$attestationStatementSupportManager );
))->create(); $webauthnSerializer = $webauthnSerializerFactory->create();
$publicKeyCredential = $webauthnSerializer->deserialize( $publicKeyCredential = $webauthnSerializer->deserialize(
json_encode($request->all(), JSON_THROW_ON_ERROR), json_encode($request->all(), JSON_THROW_ON_ERROR),
@ -146,11 +161,11 @@ class PasskeysController extends Controller
$ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler(
ExtensionOutputCheckerHandler::create() ExtensionOutputCheckerHandler::create()
); );
$securedRelyingPartyId = []; $allowedOrigins = [];
if (App::environment('local', 'development')) { if (App::environment('local', 'development')) {
$securedRelyingPartyId = [config('url.longurl')]; $allowedOrigins = [config('app.url')];
} }
$ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId); $ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins);
$authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create(
ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony() ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony()
@ -165,8 +180,7 @@ class PasskeysController extends Controller
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
authenticatorAttestationResponse: $publicKeyCredential->response, authenticatorAttestationResponse: $publicKeyCredential->response,
publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions, publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions,
request: config('url.longurl'), host: config('app.url')
securedRelyingPartyId: $securedRelyingPartyId,
); );
$user->passkey()->create([ $user->passkey()->create([
@ -180,24 +194,37 @@ class PasskeysController extends Controller
]); ]);
} }
public function getRequestOptions(): JsonResponse /**
* @throws RandomException
* @throws \JsonException
*/
public function getRequestOptions(Request $request): JsonResponse
{ {
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create( $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create(
challenge: random_bytes(16), challenge: random_bytes(16),
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED
); );
$publicKeyCredentialRequestOptions = json_encode($publicKeyCredentialRequestOptions, JSON_THROW_ON_ERROR); $attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
$factory = new WebauthnSerializerFactory(
attestationStatementSupportManager: $attestationStatementSupportManager
);
$serializer = $factory->create();
$publicKeyCredentialRequestOptions = $serializer->serialize(data: $publicKeyCredentialRequestOptions, format: 'json');
session(['request_options' => $publicKeyCredentialRequestOptions]); $request->session()->put('request_options', $publicKeyCredentialRequestOptions);
return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions); return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions);
} }
/**
* @throws \JsonException
*/
public function login(Request $request): JsonResponse public function login(Request $request): JsonResponse
{ {
$requestOptions = session('request_options'); $requestOptions = session('request_options');
session()->forget('request_options'); $request->session()->forget('request_options');
if (empty($requestOptions)) { if (empty($requestOptions)) {
return response()->json([ return response()->json([
@ -209,9 +236,10 @@ class PasskeysController extends Controller
$attestationStatementSupportManager = new AttestationStatementSupportManager; $attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport); $attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$webauthnSerializer = (new WebauthnSerializerFactory( $webauthnSerializerFactory = new WebauthnSerializerFactory(
$attestationStatementSupportManager attestationStatementSupportManager: $attestationStatementSupportManager
))->create(); );
$webauthnSerializer = $webauthnSerializerFactory->create();
$publicKeyCredential = $webauthnSerializer->deserialize( $publicKeyCredential = $webauthnSerializer->deserialize(
json_encode($request->all(), JSON_THROW_ON_ERROR), json_encode($request->all(), JSON_THROW_ON_ERROR),
@ -256,11 +284,11 @@ class PasskeysController extends Controller
$ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler(
ExtensionOutputCheckerHandler::create() ExtensionOutputCheckerHandler::create()
); );
$securedRelyingPartyId = []; $allowedOrigins = [];
if (App::environment('local', 'development')) { if (App::environment('local', 'development')) {
$securedRelyingPartyId = [config('url.longurl')]; $allowedOrigins = [config('app.url')];
} }
$ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId); $ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins);
$authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create( $authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create(
ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony() ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony()
@ -274,12 +302,11 @@ class PasskeysController extends Controller
try { try {
$authenticatorAssertionResponseValidator->check( $authenticatorAssertionResponseValidator->check(
credentialId: $publicKeyCredentialSource, publicKeyCredentialSource: $publicKeyCredentialSource,
authenticatorAssertionResponse: $publicKeyCredential->response, authenticatorAssertionResponse: $publicKeyCredential->response,
publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions, publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions,
request: config('url.longurl'), host: config('app.url'),
userHandle: null, userHandle: null,
securedRelyingPartyId: $securedRelyingPartyId,
); );
} catch (Throwable) { } catch (Throwable) {
return response()->json([ return response()->json([

View file

@ -10,9 +10,6 @@ use App\Services\PlaceService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class PlacesController extends Controller class PlacesController extends Controller
{ {
protected PlaceService $placeService; protected PlaceService $placeService;

View file

@ -10,9 +10,6 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class SyndicationTargetsController extends Controller class SyndicationTargetsController extends Controller
{ {
/** /**

View file

@ -10,9 +10,6 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
use Jonnybarnes\IndieWeb\Numbers; use Jonnybarnes\IndieWeb\Numbers;
/**
* @psalm-suppress UnusedClass
*/
class ArticlesController extends Controller class ArticlesController extends Controller
{ {
/** /**

View file

@ -9,9 +9,6 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class AuthController extends Controller class AuthController extends Controller
{ {
/** /**

View file

@ -7,9 +7,6 @@ namespace App\Http\Controllers;
use App\Models\Bookmark; use App\Models\Bookmark;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class BookmarksController extends Controller class BookmarksController extends Controller
{ {
/** /**

View file

@ -8,9 +8,6 @@ use App\Models\Contact;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class ContactsController extends Controller class ContactsController extends Controller
{ {
/** /**

View file

@ -9,9 +9,6 @@ use App\Models\Note;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response; use Illuminate\Http\Response;
/**
* @psalm-suppress UnusedClass
*/
class FeedsController extends Controller class FeedsController extends Controller
{ {
/** /**
@ -122,8 +119,8 @@ class FeedsController extends Controller
foreach ($notes as $key => $note) { foreach ($notes as $key => $note) {
$data['items'][$key] = [ $data['items'][$key] = [
'id' => $note->longurl, 'id' => $note->uri,
'url' => $note->longurl, 'url' => $note->uri,
'content_text' => $note->content, 'content_text' => $note->content,
'date_published' => $note->created_at->tz('UTC')->toRfc3339String(), 'date_published' => $note->created_at->tz('UTC')->toRfc3339String(),
'date_modified' => $note->updated_at->tz('UTC')->toRfc3339String(), 'date_modified' => $note->updated_at->tz('UTC')->toRfc3339String(),
@ -164,7 +161,7 @@ class FeedsController extends Controller
'author' => [ 'author' => [
'type' => 'card', 'type' => 'card',
'name' => config('user.display_name'), 'name' => config('user.display_name'),
'url' => config('url.longurl'), 'url' => config('app.url'),
], ],
'children' => $items, 'children' => $items,
], 200, [ ], 200, [
@ -183,8 +180,8 @@ class FeedsController extends Controller
$items[] = [ $items[] = [
'type' => 'entry', 'type' => 'entry',
'published' => $note->created_at, 'published' => $note->created_at,
'uid' => $note->longurl, 'uid' => $note->uri,
'url' => $note->longurl, 'url' => $note->uri,
'content' => [ 'content' => [
'text' => $note->getRawOriginal('note'), 'text' => $note->getRawOriginal('note'),
'html' => $note->note, 'html' => $note->note,
@ -200,7 +197,7 @@ class FeedsController extends Controller
'author' => [ 'author' => [
'type' => 'card', 'type' => 'card',
'name' => config('user.display_name'), 'name' => config('user.display_name'),
'url' => config('url.longurl'), 'url' => config('app.url'),
], ],
'children' => $items, 'children' => $items,
], 200, [ ], 200, [

View file

@ -10,9 +10,6 @@ use App\Models\Note;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class FrontPageController extends Controller class FrontPageController extends Controller
{ {
/** /**

View file

@ -7,9 +7,6 @@ namespace App\Http\Controllers;
use App\Models\Like; use App\Models\Like;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class LikesController extends Controller class LikesController extends Controller
{ {
/** /**

View file

@ -4,123 +4,73 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Responses\MicropubResponses; use App\Exceptions\InvalidTokenScopeException;
use App\Exceptions\MicropubHandlerException;
use App\Http\Requests\MicropubRequest;
use App\Models\Place; use App\Models\Place;
use App\Models\SyndicationTarget; use App\Models\SyndicationTarget;
use App\Services\Micropub\HCardService; use App\Services\Micropub\MicropubHandlerRegistry;
use App\Services\Micropub\HEntryService;
use App\Services\Micropub\UpdateService;
use App\Services\TokenService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Encoding\CannotDecodeContent; use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
/**
* @psalm-suppress UnusedClass
*/
class MicropubController extends Controller class MicropubController extends Controller
{ {
protected TokenService $tokenService; protected MicropubHandlerRegistry $handlerRegistry;
protected HEntryService $hentryService; public function __construct(MicropubHandlerRegistry $handlerRegistry)
{
protected HCardService $hcardService; $this->handlerRegistry = $handlerRegistry;
protected UpdateService $updateService;
public function __construct(
TokenService $tokenService,
HEntryService $hentryService,
HCardService $hcardService,
UpdateService $updateService
) {
$this->tokenService = $tokenService;
$this->hentryService = $hentryService;
$this->hcardService = $hcardService;
$this->updateService = $updateService;
} }
/** /**
* This function receives an API request, verifies the authenticity * Respond to a POST request to the micropub endpoint.
* then passes over the info to the relevant Service class. *
* The request is initially processed by the MicropubRequest form request
* class. The normalizes the data, so we can pass it into the handlers for
* the different micropub requests, h-entry or h-card, for example.
*/ */
public function post(Request $request): JsonResponse public function post(MicropubRequest $request): JsonResponse
{ {
$type = $request->getType();
if (! $type) {
return response()->json([
'error' => 'invalid_request',
'error_description' => 'Microformat object type is missing, for example: h-entry or h-card',
], 400);
}
try { try {
$tokenData = $this->tokenService->validateToken($request->input('access_token')); $handler = $this->handlerRegistry->getHandler($type);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { $result = $handler->handle($request->getMicropubData());
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$this->logMicropubRequest($request->all());
if (($request->input('h') === 'entry') || ($request->input('type.0') === 'h-entry')) {
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
}
$location = $this->hentryService->process($request->all(), $this->getCLientId());
// Return appropriate response based on the handler result
return response()->json([ return response()->json([
'response' => 'created', 'response' => $result['response'],
'location' => $location, 'location' => $result['url'] ?? null,
], 201)->header('Location', $location); ], 201)->header('Location', $result['url']);
} } catch (\InvalidArgumentException $e) {
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)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
}
$location = $this->hcardService->process($request->all());
return response()->json([ return response()->json([
'response' => 'created', 'error' => 'invalid_request',
'location' => $location, 'error_description' => $e->getMessage(),
], 201)->header('Location', $location); ], 400);
} } catch (MicropubHandlerException) {
if ($request->input('action') === 'update') {
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
}
return $this->updateService->process($request->all());
}
return response()->json([ return response()->json([
'response' => 'error', 'error' => 'Unknown Micropub type',
'error_description' => 'unsupported_request_type', 'error_description' => 'The request could not be processed by this server',
], 500); ], 500);
} catch (InvalidTokenScopeException) {
return response()->json([
'error' => 'invalid_scope',
'error_description' => 'The token does not have the required scope for this request',
], 403);
} catch (\Exception) {
return response()->json([
'error' => 'server_error',
'error_description' => 'An error occurred processing the request',
], 500);
}
} }
/** /**
@ -133,12 +83,6 @@ class MicropubController extends Controller
*/ */
public function get(Request $request): JsonResponse public function get(Request $request): JsonResponse
{ {
try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
return (new MicropubResponses)->invalidTokenResponse();
}
if ($request->input('q') === 'syndicate-to') { if ($request->input('q') === 'syndicate-to') {
return response()->json([ return response()->json([
'syndicate-to' => SyndicationTarget::all(), 'syndicate-to' => SyndicationTarget::all(),
@ -170,36 +114,17 @@ class MicropubController extends Controller
]); ]);
} }
// default response is just to return the token data // the default response is just to return the token data
/** @var Token $tokenData */
$tokenData = $request->input('token_data');
return response()->json([ return response()->json([
'response' => 'token', 'response' => 'token',
'token' => [ 'token' => [
'me' => $tokenData->claims()->get('me'), 'me' => $tokenData['me'],
'scope' => $tokenData->claims()->get('scope'), 'scope' => $tokenData['scope'],
'client_id' => $tokenData->claims()->get('client_id'), 'client_id' => $tokenData['client_id'],
], ],
]); ]);
} }
/**
* Determine the client id from the access token sent with the request.
*
* @throws RequiredConstraintsViolated
*/
private function getClientId(): string
{
return resolve(TokenService::class)
->validateToken(app('request')->input('access_token'))
->claims()->get('client_id');
}
/**
* Save the details of the micropub request to a log file.
*/
private function logMicropubRequest(array $request): void
{
$logger = new Logger('micropub');
$logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')));
$logger->debug('MicropubLog', $request);
}
} }

View file

@ -7,10 +7,8 @@ namespace App\Http\Controllers;
use App\Http\Responses\MicropubResponses; use App\Http\Responses\MicropubResponses;
use App\Jobs\ProcessMedia; use App\Jobs\ProcessMedia;
use App\Models\Media; use App\Models\Media;
use App\Services\TokenService;
use Exception; use Exception;
use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@ -18,46 +16,20 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
/**
* @psalm-suppress UnusedClass
*/
class MicropubMediaController extends Controller class MicropubMediaController extends Controller
{ {
protected TokenService $tokenService;
public function __construct(TokenService $tokenService)
{
$this->tokenService = $tokenService;
}
public function getHandler(Request $request): JsonResponse public function getHandler(Request $request): JsonResponse
{ {
try { $tokenData = $request->input('token_data');
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); $scopes = $tokenData['scope'];
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes)) { if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses; return (new MicropubResponses)->insufficientScopeResponse();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->input('q') === 'last') { if ($request->input('q') === 'last') {
@ -108,28 +80,14 @@ class MicropubMediaController extends Controller
*/ */
public function media(Request $request): JsonResponse public function media(Request $request): JsonResponse
{ {
try { $tokenData = $request->input('token_data');
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); $scopes = $tokenData['scope'];
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes)) { if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses; return (new MicropubResponses)->insufficientScopeResponse();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->hasFile('file') === false) { if ($request->hasFile('file') === false) {
@ -164,7 +122,7 @@ class MicropubMediaController extends Controller
} }
$media = Media::create([ $media = Media::create([
'token' => $request->bearerToken(), 'token' => $request->input('access_token'),
'path' => $filename, 'path' => $filename,
'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()), 'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()),
'image_widths' => $width, 'image_widths' => $width,

View file

@ -14,8 +14,6 @@ use Jonnybarnes\IndieWeb\Numbers;
/** /**
* @todo Need to sort out Twitter and webmentions! * @todo Need to sort out Twitter and webmentions!
*
* @psalm-suppress UnusedClass
*/ */
class NotesController extends Controller class NotesController extends Controller
{ {

View file

@ -7,9 +7,6 @@ namespace App\Http\Controllers;
use App\Models\Place; use App\Models\Place;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class PlacesController extends Controller class PlacesController extends Controller
{ {
/** /**

View file

@ -6,9 +6,6 @@ use App\Models\Note;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
/**
* @psalm-suppress UnusedClass
*/
class SearchController extends Controller class SearchController extends Controller
{ {
public function search(Request $request): View public function search(Request $request): View

View file

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
/**
* @psalm-suppress UnusedClass
*/
class ShortURLsController extends Controller
{
/*
|--------------------------------------------------------------------------
| Short URL Controller
|--------------------------------------------------------------------------
|
| This redirects the short urls to long ones
|
*/
/**
* Redirect from '/' to the long url.
*/
public function baseURL(): RedirectResponse
{
return redirect(config('app.url'));
}
/**
* Redirect from '/@' to a twitter profile.
*/
public function twitter(): RedirectResponse
{
return redirect('https://twitter.com/jonnybarnes');
}
/**
* Redirect a short url of this site out to a long one based on post type.
*
* Further redirects may happen.
*/
public function expandType(string $type, string $postId): RedirectResponse
{
if ($type === 't') {
$type = 'notes';
}
if ($type === 'b') {
$type = 'blog/s';
}
return redirect(config('app.url') . '/' . $type . '/' . $postId);
}
}

View file

@ -12,9 +12,6 @@ use Illuminate\Http\Response;
use Illuminate\View\View; use Illuminate\View\View;
use Jonnybarnes\IndieWeb\Numbers; use Jonnybarnes\IndieWeb\Numbers;
/**
* @psalm-suppress UnusedClass
*/
class WebMentionsController extends Controller class WebMentionsController extends Controller
{ {
/** /**

View file

@ -10,8 +10,6 @@ class CorsHeaders
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {

View file

@ -10,8 +10,6 @@ class LinkHeadersMiddleware
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {

View file

@ -14,8 +14,6 @@ class LocalhostSessionMiddleware
* Whilst we are developing locally, automatically log in as * Whilst we are developing locally, automatically log in as
* `['me' => config('app.url')]` as I cant manually log in as * `['me' => config('app.url')]` as I cant manually log in as
* a .localhost domain. * a .localhost domain.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
class LogMicropubRequest
{
public function handle(Request $request, Closure $next): Response|JsonResponse
{
$logger = new Logger('micropub');
$logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')));
$logger->debug('MicropubLog', $request->all());
return $next($request);
}
}

View file

@ -13,8 +13,6 @@ class MyAuthMiddleware
{ {
/** /**
* Check the user is logged in. * Check the user is logged in.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {

View file

@ -10,8 +10,6 @@ class ValidateSignature extends Middleware
* The names of the query string parameters that should be ignored. * The names of the query string parameters that should be ignored.
* *
* @var array<int, string> * @var array<int, string>
*
* @psalm-suppress PossiblyUnusedProperty
*/ */
protected $except = [ protected $except = [
// 'fbclid', // 'fbclid',

View file

@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken class VerifyMicropubToken
@ -13,24 +19,63 @@ class VerifyMicropubToken
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @psalm-suppress PossiblyUnusedMethod * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$rawToken = null;
if ($request->input('access_token')) { if ($request->input('access_token')) {
return $next($request); $rawToken = $request->input('access_token');
} } elseif ($request->bearerToken()) {
$rawToken = $request->bearerToken();
if ($request->bearerToken()) {
return $next($request->merge([
'access_token' => $request->bearerToken(),
]));
} }
if (! $rawToken) {
return response()->json([ return response()->json([
'response' => 'error', 'response' => 'error',
'error' => 'unauthorized', 'error' => 'unauthorized',
'error_description' => 'No access token was provided in the request', 'error_description' => 'No access token was provided in the request',
], 401); ], 401);
} }
try {
$tokenData = $this->validateToken($rawToken);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
return $next($request->merge([
'access_token' => $rawToken,
'token_data' => [
'me' => $tokenData->claims()->get('me'),
'scope' => $tokenData->claims()->get('scope'),
'client_id' => $tokenData->claims()->get('client_id'),
],
]));
}
/**
* Check the token signature is valid.
*/
private function validateToken(string $bearerToken): Token
{
$config = resolve(Configuration::class);
$token = $config->parser()->parse($bearerToken);
$constraints = $config->validationConstraints();
$config->validator()->assert($token, ...$constraints);
return $token;
}
} }

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
class MicropubRequest extends FormRequest
{
protected array $micropubData = [];
public function rules(): array
{
return [
// Validation rules
];
}
public function getMicropubData(): array
{
return $this->micropubData;
}
public function getType(): ?string
{
// Return consistent type regardless of input format
return $this->micropubData['type'] ?? null;
}
protected function prepareForValidation(): void
{
// Normalize the request data based on content type
if ($this->isJson()) {
$this->normalizeMicropubJson();
} else {
$this->normalizeMicropubForm();
}
}
private function normalizeMicropubJson(): void
{
$json = $this->json();
if ($json === null) {
throw new \InvalidArgumentException('`isJson()` passed but there is no json data');
}
$data = $json->all();
// Convert JSON type (h-entry) to simple type (entry)
if (isset($data['type']) && is_array($data['type'])) {
$type = current($data['type']);
if (strpos($type, 'h-') === 0) {
$this->micropubData['type'] = substr($type, 2);
}
}
// Or set the type to update
elseif (isset($data['action']) && $data['action'] === 'update') {
$this->micropubData['type'] = 'update';
}
// Add in the token data
$this->micropubData['token_data'] = $data['token_data'];
// Add h-entry values
$this->micropubData['content'] = Arr::get($data, 'properties.content.0');
$this->micropubData['in-reply-to'] = Arr::get($data, 'properties.in-reply-to.0');
$this->micropubData['published'] = Arr::get($data, 'properties.published.0');
$this->micropubData['location'] = Arr::get($data, 'location');
$this->micropubData['bookmark-of'] = Arr::get($data, 'properties.bookmark-of.0');
$this->micropubData['like-of'] = Arr::get($data, 'properties.like-of.0');
$this->micropubData['mp-syndicate-to'] = Arr::get($data, 'properties.mp-syndicate-to');
// Add h-card values
$this->micropubData['name'] = Arr::get($data, 'properties.name.0');
$this->micropubData['description'] = Arr::get($data, 'properties.description.0');
$this->micropubData['geo'] = Arr::get($data, 'properties.geo.0');
// Add checkin value
$this->micropubData['checkin'] = Arr::get($data, 'checkin');
$this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0');
}
private function normalizeMicropubForm(): void
{
// Convert form h=entry to type=entry
if ($h = $this->input('h')) {
$this->micropubData['type'] = $h;
}
// Add some fields to the micropub data with default null values
$this->micropubData['in-reply-to'] = null;
$this->micropubData['published'] = null;
$this->micropubData['location'] = null;
$this->micropubData['description'] = null;
$this->micropubData['geo'] = null;
$this->micropubData['latitude'] = null;
$this->micropubData['longitude'] = null;
// Map form fields to micropub data
foreach ($this->except(['h', 'access_token']) as $key => $value) {
$this->micropubData[$key] = $value;
}
}
}

View file

@ -53,7 +53,7 @@ class ProcessWebMention implements ShouldQueue
// check webmention still references target // check webmention still references target
// we try each type of mention (reply/like/repost) // we try each type of mention (reply/like/repost)
if ($webmention->type === 'in-reply-to') { if ($webmention->type === 'in-reply-to') {
if ($parser->checkInReplyTo($microformats, $this->note->longurl) === false) { if ($parser->checkInReplyTo($microformats, $this->note->uri) === false) {
// it doesnt so delete // it doesnt so delete
$webmention->delete(); $webmention->delete();
@ -67,7 +67,7 @@ class ProcessWebMention implements ShouldQueue
return; return;
} }
if ($webmention->type === 'like-of') { if ($webmention->type === 'like-of') {
if ($parser->checkLikeOf($microformats, $this->note->longurl) === false) { if ($parser->checkLikeOf($microformats, $this->note->uri) === false) {
// it doesnt so delete // it doesnt so delete
$webmention->delete(); $webmention->delete();
@ -75,7 +75,7 @@ class ProcessWebMention implements ShouldQueue
} // note we dont need to do anything if it still is a like } // note we dont need to do anything if it still is a like
} }
if ($webmention->type === 'repost-of') { if ($webmention->type === 'repost-of') {
if ($parser->checkRepostOf($microformats, $this->note->longurl) === false) { if ($parser->checkRepostOf($microformats, $this->note->uri) === false) {
// it doesnt so delete // it doesnt so delete
$webmention->delete(); $webmention->delete();
@ -89,7 +89,7 @@ class ProcessWebMention implements ShouldQueue
$type = $parser->getMentionType($microformats); // throw error here? $type = $parser->getMentionType($microformats); // throw error here?
dispatch(new SaveProfileImage($microformats)); dispatch(new SaveProfileImage($microformats));
$webmention->source = $this->source; $webmention->source = $this->source;
$webmention->target = $this->note->longurl; $webmention->target = $this->note->uri;
$webmention->commentable_id = $this->note->id; $webmention->commentable_id = $this->note->id;
$webmention->commentable_type = Note::class; $webmention->commentable_type = Note::class;
$webmention->type = $type; $webmention->type = $type;

View file

@ -45,7 +45,7 @@ class SendWebMentions implements ShouldQueue
$guzzle = resolve(Client::class); $guzzle = resolve(Client::class);
$guzzle->post($endpoint, [ $guzzle->post($endpoint, [
'form_params' => [ 'form_params' => [
'source' => $this->note->longurl, 'source' => $this->note->uri,
'target' => $url, 'target' => $url,
], ],
]); ]);
@ -61,7 +61,7 @@ class SendWebMentions implements ShouldQueue
public function discoverWebmentionEndpoint(string $url): ?string public function discoverWebmentionEndpoint(string $url): ?string
{ {
// lets not send webmentions to myself // lets not send webmentions to myself
if (parse_url($url, PHP_URL_HOST) === config('url.longurl')) { if (parse_url($url, PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST)) {
return null; return null;
} }
if (Str::startsWith($url, '/notes/tagged/')) { if (Str::startsWith($url, '/notes/tagged/')) {

View file

@ -26,7 +26,7 @@ class Bookmark extends Model
return $this->belongsToMany('App\Models\Tag'); return $this->belongsToMany('App\Models\Tag');
} }
protected function longurl(): Attribute protected function local_uri(): Attribute
{ {
return Attribute::get( return Attribute::get(
get: fn () => config('app.url') . '/bookmarks/' . $this->id, get: fn () => config('app.url') . '/bookmarks/' . $this->id,

View file

@ -124,7 +124,7 @@ class Note extends Model
public function getNoteAttribute(?string $value): ?string public function getNoteAttribute(?string $value): ?string
{ {
if ($value === null && $this->place !== null) { if ($value === null && $this->place !== null) {
$value = '📍: <a href="' . $this->place->longurl . '">' . $this->place->name . '</a>'; $value = '📍: <a href="' . $this->place->uri . '">' . $this->place->name . '</a>';
} }
// if $value is still null, just return null // if $value is still null, just return null
@ -172,16 +172,11 @@ class Note extends Model
return (string) resolve(Numbers::class)->numto60($this->id); return (string) resolve(Numbers::class)->numto60($this->id);
} }
public function getLongurlAttribute(): string public function getUriAttribute(): string
{ {
return config('app.url') . '/notes/' . $this->nb60id; return config('app.url') . '/notes/' . $this->nb60id;
} }
public function getShorturlAttribute(): string
{
return config('url.shorturl') . '/notes/' . $this->nb60id;
}
public function getIso8601Attribute(): string public function getIso8601Attribute(): string
{ {
return $this->updated_at->toISO8601String(); return $this->updated_at->toISO8601String();

View file

@ -74,24 +74,10 @@ class Place extends Model
])); ]));
} }
protected function longurl(): Attribute
{
return Attribute::get(
get: fn ($value, $attributes) => config('app.url') . '/places/' . $attributes['slug'],
);
}
protected function shorturl(): Attribute
{
return Attribute::get(
get: fn ($value, $attributes) => config('url.shorturl') . '/places/' . $attributes['slug'],
);
}
protected function uri(): Attribute protected function uri(): Attribute
{ {
return Attribute::get( return Attribute::get(
get: fn () => $this->longurl, get: static fn ($value, $attributes) => config('app.url') . '/places/' . $attributes['slug'],
); );
} }

View file

@ -9,15 +9,10 @@ use App\Models\Tag;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/**
* @todo Do we need psalm-suppress for these observer methods?
*/
class NoteObserver class NoteObserver
{ {
/** /**
* Listen to the Note created event. * Listen to the Note created event.=
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function created(Note $note): void public function created(Note $note): void
{ {
@ -39,9 +34,7 @@ class NoteObserver
} }
/** /**
* Listen to the Note updated event. * Listen to the Note updated event.=
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function updated(Note $note): void public function updated(Note $note): void
{ {
@ -65,9 +58,7 @@ class NoteObserver
} }
/** /**
* Listen to the Note deleting event. * Listen to the Note deleting event.=
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function deleting(Note $note): void public function deleting(Note $note): void
{ {

View file

@ -5,9 +5,6 @@ namespace App\Providers;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\HorizonApplicationServiceProvider; use Laravel\Horizon\HorizonApplicationServiceProvider;
/**
* @psalm-suppress UnusedClass
*/
class HorizonServiceProvider extends HorizonApplicationServiceProvider class HorizonServiceProvider extends HorizonApplicationServiceProvider
{ {
/** /**

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Services\Micropub\CardHandler;
use App\Services\Micropub\EntryHandler;
use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Support\ServiceProvider;
class MicropubServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(MicropubHandlerRegistry::class, function () {
$registry = new MicropubHandlerRegistry;
// Register handlers
$registry->register('card', new CardHandler);
$registry->register('entry', new EntryHandler);
return $registry;
});
}
}

View file

@ -6,13 +6,13 @@ namespace App\Services;
use App\Models\Article; use App\Models\Article;
class ArticleService extends Service class ArticleService
{ {
public function create(array $request, ?string $client = null): Article public function create(array $data): Article
{ {
return Article::create([ return Article::create([
'title' => $this->getDataByKey($request, 'name'), 'title' => $data['name'],
'main' => $this->getDataByKey($request, 'content'), 'main' => $data['content'],
'published' => true, 'published' => true,
]); ]);
} }

View file

@ -10,28 +10,29 @@ use App\Models\Bookmark;
use App\Models\Tag; use App\Models\Tag;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class BookmarkService extends Service class BookmarkService
{ {
/** /**
* Create a new Bookmark. * Create a new Bookmark.
*/ */
public function create(array $request, ?string $client = null): Bookmark public function create(array $data): Bookmark
{ {
if (Arr::get($request, 'properties.bookmark-of.0')) { if (Arr::get($data, 'properties.bookmark-of.0')) {
// micropub request // micropub request
$url = normalize_url(Arr::get($request, 'properties.bookmark-of.0')); $url = normalize_url(Arr::get($data, 'properties.bookmark-of.0'));
$name = Arr::get($request, 'properties.name.0'); $name = Arr::get($data, 'properties.name.0');
$content = Arr::get($request, 'properties.content.0'); $content = Arr::get($data, 'properties.content.0');
$categories = Arr::get($request, 'properties.category'); $categories = Arr::get($data, 'properties.category');
} }
if (Arr::get($request, 'bookmark-of')) { if (Arr::get($data, 'bookmark-of')) {
$url = normalize_url(Arr::get($request, 'bookmark-of')); $url = normalize_url(Arr::get($data, 'bookmark-of'));
$name = Arr::get($request, 'name'); $name = Arr::get($data, 'name');
$content = Arr::get($request, 'content'); $content = Arr::get($data, 'content');
$categories = Arr::get($request, 'category'); $categories = Arr::get($data, 'category');
} }
$bookmark = Bookmark::create([ $bookmark = Bookmark::create([
@ -54,6 +55,7 @@ class BookmarkService extends Service
* Given a URL, attempt to save it to the Internet Archive. * Given a URL, attempt to save it to the Internet Archive.
* *
* @throws InternetArchiveException * @throws InternetArchiveException
* @throws GuzzleException
*/ */
public function getArchiveLink(string $url): string public function getArchiveLink(string $url): string
{ {

View file

@ -8,19 +8,19 @@ use App\Jobs\ProcessLike;
use App\Models\Like; use App\Models\Like;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class LikeService extends Service class LikeService
{ {
/** /**
* Create a new Like. * Create a new Like.
*/ */
public function create(array $request, ?string $client = null): Like public function create(array $data): Like
{ {
if (Arr::get($request, 'properties.like-of.0')) { if (Arr::get($data, 'properties.like-of.0')) {
// micropub request // micropub request
$url = normalize_url(Arr::get($request, 'properties.like-of.0')); $url = normalize_url(Arr::get($data, 'properties.like-of.0'));
} }
if (Arr::get($request, 'like-of')) { if (Arr::get($data, 'like-of')) {
$url = normalize_url(Arr::get($request, 'like-of')); $url = normalize_url(Arr::get($data, 'like-of'));
} }
$like = Like::create(['url' => $url]); $like = Like::create(['url' => $url]);

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Services\PlaceService;
class CardHandler implements MicropubHandlerInterface
{
/**
* @throws InvalidTokenScopeException
*/
public function handle(array $data): array
{
// Handle h-card requests
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$location = resolve(PlaceService::class)->createPlace($data)->uri;
return [
'response' => 'created',
'url' => $location,
];
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Services\ArticleService;
use App\Services\BookmarkService;
use App\Services\LikeService;
use App\Services\NoteService;
class EntryHandler implements MicropubHandlerInterface
{
/**
* @throws InvalidTokenScopeException
*/
public function handle(array $data)
{
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$location = match (true) {
isset($data['like-of']) => resolve(LikeService::class)->create($data)->url,
isset($data['bookmark-of']) => resolve(BookmarkService::class)->create($data)->uri,
isset($data['name']) => resolve(ArticleService::class)->create($data)->link,
default => resolve(NoteService::class)->create($data)->uri,
};
return [
'response' => 'created',
'url' => $location,
];
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Services\PlaceService;
use Illuminate\Support\Arr;
class HCardService
{
/**
* Create a Place from h-card data, return the URL.
*/
public function process(array $request): string
{
$data = [];
if (Arr::get($request, 'properties.name')) {
$data['name'] = Arr::get($request, 'properties.name');
$data['description'] = Arr::get($request, 'properties.description');
$data['geo'] = Arr::get($request, 'properties.geo');
} else {
$data['name'] = Arr::get($request, 'name');
$data['description'] = Arr::get($request, 'description');
$data['geo'] = Arr::get($request, 'geo');
$data['latitude'] = Arr::get($request, 'latitude');
$data['longitude'] = Arr::get($request, 'longitude');
}
return resolve(PlaceService::class)->createPlace($data)->longurl;
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Services\ArticleService;
use App\Services\BookmarkService;
use App\Services\LikeService;
use App\Services\NoteService;
use Illuminate\Support\Arr;
class HEntryService
{
/**
* Create the relevant model from some h-entry data.
*/
public function process(array $request, ?string $client = null): ?string
{
if (Arr::get($request, 'properties.like-of') || Arr::get($request, 'like-of')) {
return resolve(LikeService::class)->create($request)->longurl;
}
if (Arr::get($request, 'properties.bookmark-of') || Arr::get($request, 'bookmark-of')) {
return resolve(BookmarkService::class)->create($request)->longurl;
}
if (Arr::get($request, 'properties.name') || Arr::get($request, 'name')) {
return resolve(ArticleService::class)->create($request)->longurl;
}
return resolve(NoteService::class)->create($request, $client)->longurl;
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
interface MicropubHandlerInterface
{
public function handle(array $data);
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\MicropubHandlerException;
class MicropubHandlerRegistry
{
/**
* @var MicropubHandlerInterface[]
*/
protected array $handlers = [];
public function register(string $type, MicropubHandlerInterface $handler): self
{
$this->handlers[$type] = $handler;
return $this;
}
/**
* @throws MicropubHandlerException
*/
public function getHandler(string $type): MicropubHandlerInterface
{
if (! isset($this->handlers[$type])) {
throw new MicropubHandlerException("No handler registered for '{$type}'");
}
return $this->handlers[$type];
}
}

View file

@ -4,21 +4,33 @@ declare(strict_types=1);
namespace App\Services\Micropub; namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class UpdateService /*
* @todo Implement this properly
*/
class UpdateHandler implements MicropubHandlerInterface
{ {
/** /**
* Process a micropub request to update an entry. * @throws InvalidTokenScopeException
*/ */
public function process(array $request): JsonResponse public function handle(array $data)
{ {
$urlPath = parse_url(Arr::get($request, 'url'), PHP_URL_PATH); $scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$urlPath = parse_url(Arr::get($data, 'url'), PHP_URL_PATH);
// is it a note we are updating? // is it a note we are updating?
if (mb_substr($urlPath, 1, 5) !== 'notes') { if (mb_substr($urlPath, 1, 5) !== 'notes') {
@ -30,7 +42,7 @@ class UpdateService
try { try {
$note = Note::nb60(basename($urlPath))->firstOrFail(); $note = Note::nb60(basename($urlPath))->firstOrFail();
} catch (ModelNotFoundException $exception) { } catch (ModelNotFoundException) {
return response()->json([ return response()->json([
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => 'No known note with given ID', 'error_description' => 'No known note with given ID',
@ -38,8 +50,8 @@ class UpdateService
} }
// got the note, are we dealing with a “replace” request? // got the note, are we dealing with a “replace” request?
if (Arr::get($request, 'replace')) { if (Arr::get($data, 'replace')) {
foreach (Arr::get($request, 'replace') as $property => $value) { foreach (Arr::get($data, 'replace') as $property => $value) {
if ($property === 'content') { if ($property === 'content') {
$note->note = $value[0]; $note->note = $value[0];
} }
@ -59,14 +71,14 @@ class UpdateService
} }
$note->save(); $note->save();
return response()->json([ return [
'response' => 'updated', 'response' => 'updated',
]); ];
} }
// how about “add” // how about “add”
if (Arr::get($request, 'add')) { if (Arr::get($data, 'add')) {
foreach (Arr::get($request, 'add') as $property => $value) { foreach (Arr::get($data, 'add') as $property => $value) {
if ($property === 'syndication') { if ($property === 'syndication') {
foreach ($value as $syndicationURL) { foreach ($value as $syndicationURL) {
if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) { if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) {

View file

@ -14,49 +14,52 @@ use App\Models\SyndicationTarget;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class NoteService extends Service class NoteService
{ {
/** /**
* Create a new note. * Create a new note.
*/ */
public function create(array $request, ?string $client = null): Note public function create(array $data): Note
{ {
// Get the content we want to save
if (is_string($data['content'])) {
$content = $data['content'];
} elseif (isset($data['content']['html'])) {
$content = $data['content']['html'];
} else {
$content = null;
}
$note = Note::create( $note = Note::create(
[ [
'note' => $this->getDataByKey($request, 'content'), 'note' => $content,
'in_reply_to' => $this->getDataByKey($request, 'in-reply-to'), 'in_reply_to' => $data['in-reply-to'],
'client_id' => $client, 'client_id' => $data['token_data']['client_id'],
] ]
); );
if ($this->getPublished($request)) { if ($published = $this->getPublished($data)) {
$note->created_at = $note->updated_at = $this->getPublished($request); $note->created_at = $note->updated_at = $published;
} }
$note->location = $this->getLocation($request); $note->location = $this->getLocation($data);
if ($this->getCheckin($request)) { if ($this->getCheckin($data)) {
$note->place()->associate($this->getCheckin($request)); $note->place()->associate($this->getCheckin($data));
$note->swarm_url = $this->getSwarmUrl($request); $note->swarm_url = $this->getSwarmUrl($data);
}
$note->instagram_url = $this->getInstagramUrl($request);
foreach ($this->getMedia($request) as $media) {
$note->media()->save($media);
} }
//
// $note->instagram_url = $this->getInstagramUrl($request);
//
// foreach ($this->getMedia($request) as $media) {
// $note->media()->save($media);
// }
$note->save(); $note->save();
dispatch(new SendWebMentions($note)); dispatch(new SendWebMentions($note));
if (in_array('mastodon', $this->getSyndicationTargets($request), true)) { $this->dispatchSyndicationJobs($note, $data);
dispatch(new SyndicateNoteToMastodon($note));
}
if (in_array('bluesky', $this->getSyndicationTargets($request), true)) {
dispatch(new SyndicateNoteToBluesky($note));
}
return $note; return $note;
} }
@ -64,14 +67,10 @@ class NoteService extends Service
/** /**
* Get the published time from the request to create a new note. * Get the published time from the request to create a new note.
*/ */
private function getPublished(array $request): ?string private function getPublished(array $data): ?string
{ {
if (Arr::get($request, 'properties.published.0')) { if ($data['published']) {
return carbon(Arr::get($request, 'properties.published.0')) return carbon($data['published'])->toDateTimeString();
->toDateTimeString();
}
if (Arr::get($request, 'published')) {
return carbon(Arr::get($request, 'published'))->toDateTimeString();
} }
return null; return null;
@ -80,12 +79,13 @@ class NoteService extends Service
/** /**
* Get the location data from the request to create a new note. * Get the location data from the request to create a new note.
*/ */
private function getLocation(array $request): ?string private function getLocation(array $data): ?string
{ {
$location = Arr::get($request, 'properties.location.0') ?? Arr::get($request, 'location'); $location = Arr::get($data, 'location');
if (is_string($location) && str_starts_with($location, 'geo:')) { if (is_string($location) && str_starts_with($location, 'geo:')) {
preg_match_all( preg_match_all(
'/([0-9\.\-]+)/', '/([0-9.\-]+)/',
$location, $location,
$matches $matches
); );
@ -99,9 +99,9 @@ class NoteService extends Service
/** /**
* Get the checkin data from the request to create a new note. This will be a Place. * Get the checkin data from the request to create a new note. This will be a Place.
*/ */
private function getCheckin(array $request): ?Place private function getCheckin(array $data): ?Place
{ {
$location = Arr::get($request, 'location'); $location = Arr::get($data, 'location');
if (is_string($location) && Str::startsWith($location, config('app.url'))) { if (is_string($location) && Str::startsWith($location, config('app.url'))) {
return Place::where( return Place::where(
'slug', 'slug',
@ -113,12 +113,12 @@ class NoteService extends Service
) )
)->first(); )->first();
} }
if (Arr::get($request, 'checkin')) { if (Arr::get($data, 'checkin')) {
try { try {
$place = resolve(PlaceService::class)->createPlaceFromCheckin( $place = resolve(PlaceService::class)->createPlaceFromCheckin(
Arr::get($request, 'checkin') Arr::get($data, 'checkin')
); );
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException) {
return null; return null;
} }
@ -142,34 +142,47 @@ class NoteService extends Service
/** /**
* Get the Swarm URL from the syndication data in the request to create a new note. * Get the Swarm URL from the syndication data in the request to create a new note.
*/ */
private function getSwarmUrl(array $request): ?string private function getSwarmUrl(array $data): ?string
{ {
if (str_contains(Arr::get($request, 'properties.syndication.0', ''), 'swarmapp')) { $syndication = Arr::get($data, 'syndication');
return Arr::get($request, 'properties.syndication.0'); if ($syndication === null) {
return null;
}
if (str_contains($syndication, 'swarmapp')) {
return $syndication;
} }
return null; return null;
} }
/** /**
* Get the syndication targets from the request to create a new note. * Dispatch syndication jobs based on the request data.
*/ */
private function getSyndicationTargets(array $request): array private function dispatchSyndicationJobs(Note $note, array $request): void
{ {
$syndication = []; // If no syndication targets are specified, return early
$mpSyndicateTo = Arr::get($request, 'mp-syndicate-to') ?? Arr::get($request, 'properties.mp-syndicate-to'); if (empty($request['mp-syndicate-to'])) {
$mpSyndicateTo = Arr::wrap($mpSyndicateTo); return;
foreach ($mpSyndicateTo as $uid) {
$target = SyndicationTarget::where('uid', $uid)->first();
if ($target && $target->service_name === 'Mastodon') {
$syndication[] = 'mastodon';
}
if ($target && $target->service_name === 'Bluesky') {
$syndication[] = 'bluesky';
}
} }
return $syndication; // Get the configured syndication targets
$syndicationTargets = SyndicationTarget::all();
foreach ($syndicationTargets as $target) {
// Check if the target is in the request data
if (in_array($target->uid, $request['mp-syndicate-to'], true)) {
// Dispatch the appropriate job based on the target service name
switch ($target->service_name) {
case 'Mastodon':
dispatch(new SyndicateNoteToMastodon($note));
break;
case 'Bluesky':
dispatch(new SyndicateNoteToBluesky($note));
break;
}
}
}
} }
/** /**

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
abstract class Service
{
abstract public function create(array $request, ?string $client = null): Model;
protected function getDataByKey(array $request, string $key): ?string
{
if (Arr::get($request, "properties.{$key}.0.html")) {
return Arr::get($request, "properties.{$key}.0.html");
}
if (is_string(Arr::get($request, "properties.{$key}.0"))) {
return Arr::get($request, "properties.{$key}.0");
}
if (is_string(Arr::get($request, "properties.{$key}"))) {
return Arr::get($request, "properties.{$key}");
}
return Arr::get($request, $key);
}
}

View file

@ -7,7 +7,6 @@ namespace App\Services;
use App\Jobs\AddClientToDatabase; use App\Jobs\AddClientToDatabase;
use DateTimeImmutable; use DateTimeImmutable;
use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Token;
class TokenService class TokenService
{ {
@ -30,20 +29,4 @@ class TokenService
return $token->toString(); return $token->toString();
} }
/**
* Check the token signature is valid.
*/
public function validateToken(string $bearerToken): Token
{
$config = resolve('Lcobucci\JWT\Configuration');
$token = $config->parser()->parse($bearerToken);
$constraints = $config->validationConstraints();
$config->validator()->assert($token, ...$constraints);
return $token;
}
} }

View file

@ -3,4 +3,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class, App\Providers\HorizonServiceProvider::class,
App\Providers\MicropubServiceProvider::class,
]; ];

View file

@ -49,7 +49,8 @@
"openai-php/client": "^0.10.1", "openai-php/client": "^0.10.1",
"phpunit/php-code-coverage": "^11.0", "phpunit/php-code-coverage": "^11.0",
"phpunit/phpunit": "^11.0", "phpunit/phpunit": "^11.0",
"spatie/laravel-ray": "^1.12" "spatie/laravel-ray": "^1.12",
"spatie/x-ray": "^1.2"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

1266
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
<?php
/*
* Here we set the long and short URLs our app shall use
* You can override these settings in the .env file
*/
return [
/*
|--------------------------------------------------------------------------
| Application Long URL
|--------------------------------------------------------------------------
|
| The long URL for the application
|
*/
'longurl' => env('APP_LONGURL', 'longurl.local'),
/*
|--------------------------------------------------------------------------
| Application Short URL
|--------------------------------------------------------------------------
|
| The short URL for the application
|
*/
'shorturl' => env('APP_SHORTURL', 'shorturl.local'),
];

View file

@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article>
*/ */
class ArticleFactory extends Factory class ArticleFactory extends Factory

View file

@ -5,8 +5,6 @@ namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bio> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bio>
*/ */
class BioFactory extends Factory class BioFactory extends Factory

View file

@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bookmark> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bookmark>
*/ */
class BookmarkFactory extends Factory class BookmarkFactory extends Factory

View file

@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Contact> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Contact>
*/ */
class ContactFactory extends Factory class ContactFactory extends Factory

View file

@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Like> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Like>
*/ */
class LikeFactory extends Factory class LikeFactory extends Factory

View file

@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Media> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Media>
*/ */
class MediaFactory extends Factory class MediaFactory extends Factory

View file

@ -6,8 +6,6 @@ use App\Models\MicropubClient;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\MicropubClient> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\MicropubClient>
*/ */
class MicropubClientFactory extends Factory class MicropubClientFactory extends Factory

View file

@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Note> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Note>
*/ */
class NoteFactory extends Factory class NoteFactory extends Factory

View file

@ -6,8 +6,6 @@ use App\Models\Place;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Place> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Place>
*/ */
class PlaceFactory extends Factory class PlaceFactory extends Factory

View file

@ -5,8 +5,6 @@ namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SyndicationTarget> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SyndicationTarget>
*/ */
class SyndicationTargetFactory extends Factory class SyndicationTargetFactory extends Factory

View file

@ -6,8 +6,6 @@ use App\Models\Tag;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Tag> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Tag>
*/ */
class TagFactory extends Factory class TagFactory extends Factory

View file

@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/ */
class UserFactory extends Factory class UserFactory extends Factory

View file

@ -6,8 +6,6 @@ use App\Models\WebMention;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @psalm-suppress UnusedClass
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WebMention> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WebMention>
*/ */
class WebMentionFactory extends Factory class WebMentionFactory extends Factory

View file

@ -11,8 +11,6 @@ class ArticlesTableSeeder extends Seeder
{ {
/** /**
* Seed the articles table. * Seed the articles table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -5,9 +5,6 @@ namespace Database\Seeders;
use App\Models\Bio; use App\Models\Bio;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
/**
* @psalm-suppress UnusedClass
*/
class BioSeeder extends Seeder class BioSeeder extends Seeder
{ {
/** /**

View file

@ -10,8 +10,6 @@ class BookmarksTableSeeder extends Seeder
{ {
/** /**
* Seed the bookmarks table. * Seed the bookmarks table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -11,8 +11,6 @@ class ClientsTableSeeder extends Seeder
{ {
/** /**
* Seed the clients table. * Seed the clients table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -10,8 +10,6 @@ class ContactsTableSeeder extends Seeder
{ {
/** /**
* Seed the contacts table. * Seed the contacts table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -4,9 +4,6 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
/**
* @psalm-suppress UnusedClass
*/
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
/** /**

View file

@ -12,8 +12,6 @@ class LikesTableSeeder extends Seeder
{ {
/** /**
* Seed the likes table. * Seed the likes table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -14,8 +14,6 @@ class NotesTableSeeder extends Seeder
{ {
/** /**
* Seed the notes table. * Seed the notes table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -9,8 +9,6 @@ class PlacesTableSeeder extends Seeder
{ {
/** /**
* Seed the places table. * Seed the places table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -9,8 +9,6 @@ class UsersTableSeeder extends Seeder
{ {
/** /**
* Seed the users table. * Seed the users table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -9,8 +9,6 @@ class WebMentionsTableSeeder extends Seeder
{ {
/** /**
* Seed the webmentions table. * Seed the webmentions table.
*
* @psalm-suppress PossiblyUnusedMethod
*/ */
public function run(): void public function run(): void
{ {

View file

@ -2,7 +2,7 @@
services: services:
laravel.test: laravel.test:
build: build:
context: ./vendor/laravel/sail/runtimes/8.3 context: ./vendor/laravel/sail/runtimes/8.4
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
WWWGROUP: '${WWWGROUP}' WWWGROUP: '${WWWGROUP}'
@ -26,7 +26,7 @@ services:
- pgsql - pgsql
- redis - redis
pgsql: pgsql:
image: 'postgres:16' image: 'postgres:17'
ports: ports:
- '${FORWARD_DB_PORT:-5432}:5432' - '${FORWARD_DB_PORT:-5432}:5432'
environment: environment:

1533
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,21 +7,23 @@
"license": "CC0-1.0", "license": "CC0-1.0",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.6.0", "@eslint/js": "^9.6.0",
"@stylistic/eslint-plugin": "^3.0.0", "@stylistic/eslint-plugin": "^5.1.0",
"esbuild": "^0.25.2",
"eslint": "^9.7.0", "eslint": "^9.7.0",
"globals": "^15.8.0", "globals": "^16.0.0",
"lightningcss": "^1.29.3",
"lightningcss-cli": "^1.29.3",
"stylelint": "^16.7.0", "stylelint": "^16.7.0",
"stylelint-config-standard": "^37.0.0" "stylelint-config-standard": "^38.0.0"
}, },
"scripts": { "scripts": {
"eslint": "eslint public/assets/js/*.js", "eslint": "eslint public/assets/js/*.js",
"stylelint": "stylelint public/assets/css/*.css", "stylelint": "stylelint resources/css/*.css",
"lint": "npm run eslint && npm run stylelint", "lint": "npm run eslint && npm run stylelint",
"lightningcss": "lightningcss --output-dir public/assets/css --sourcemap --bundle --minify resources/css/app.css",
"fix-sourcemap": "./scripts/fix-sourcemap.sh",
"build-css": "npm run lightningcss && npm run fix-sourcemap",
"compress": "./scripts/compress.sh", "compress": "./scripts/compress.sh",
"build": "npm run lint && npm run compress" "build": "npm run lint && npm run compress"
},
"dependencies": {
"@11ty/is-land": "^4.0.0",
"@zachleat/snow-fall": "^1.0.2"
} }
} }

Some files were not shown because too many files have changed in this diff Show more