Compare commits

..

35 commits

Author SHA1 Message Date
9383ed8d0b
Merge pull request #817 from jonnybarnes/develop
MTM Remove Mapbox links
2023-05-04 18:49:21 +01:00
2962675f9d
Merge pull request #770 from jonnybarnes/develop
MTM Re-add search functionality
2023-04-12 10:19:14 +01:00
8532ee36d3
Merge pull request #766 from jonnybarnes/develop
MTM Switch bio to be stored in the database
2023-04-11 17:50:34 +01:00
22447b6027
Merge pull request #758 from jonnybarnes/develop
MTM 2023 Redesign v1
2023-04-08 17:07:18 +01:00
fcf0b4a778
Merge pull request #670 from jonnybarnes/develop
MTM: Laravel 10
2023-02-18 10:03:06 +00:00
cc782bc39a
Merge pull request #641 from jonnybarnes/develop
MTM: Add PHP 8.2 Support
2023-02-04 12:45:44 +00:00
054bba1da9
Merge pull request #639 from jonnybarnes/develop
MTM: Recent work
2023-02-04 12:22:23 +00:00
22e3eaab23
Merge pull request #559 from jonnybarnes/develop
MTM Update Failed Jobs table
2022-11-21 19:15:09 +00:00
e0efd19ef9
Merge pull request #553 from jonnybarnes/develop
MTM More Media Endpoint Improvements
2022-11-20 17:30:25 +00:00
0cd6f81a09
Merge pull request #551 from jonnybarnes/develop
MTM Improve Media Endpoint
2022-11-18 17:32:39 +00:00
30c9b2e248
Merge pull request #535 from jonnybarnes/develop
MTM Handle finding webmention targets better
2022-11-09 20:10:55 +00:00
52ac67ba7a
Merge pull request #525 from jonnybarnes/develop
MTM Post Markdown content to Mastodon
2022-11-06 10:32:03 +00:00
272a5ea2fd
Merge pull request #522 from jonnybarnes/develop
MTM Mastodon syndication
2022-11-04 17:22:22 +00:00
0bea4a1e95
Merge pull request #499 from jonnybarnes/develop
MTM Simplify migrations
2022-10-24 21:18:58 +01:00
8ec738a765
Merge pull request #497 from jonnybarnes/develop
MTM Fix error saving syndication data
2022-10-24 17:45:22 +01:00
2f548725db
Merge pull request #494 from jonnybarnes/develop
MTM Store synidaction targets in the database
2022-10-24 14:28:06 +01:00
f6546e2335
Merge pull request #477 from jonnybarnes/develop
MTM Update Twitter syndication logo
2022-10-16 18:16:38 +01:00
d52b8a3e83
Merge pull request #473 from jonnybarnes/develop
MTM Fix IndieAuth sign in
2022-10-15 16:24:35 +01:00
46133be181
Merge pull request #445 from jonnybarnes/develop
MTM Reworked indieauth checking in token endpoint
2022-09-24 19:23:18 +01:00
0faf896e8f
Merge pull request #443 from jonnybarnes/develop
MTM Fix IndieAuth sign in
2022-09-24 18:42:27 +01:00
374e231a6b
Merge pull request #420 from jonnybarnes/develop
MTM Use env for cookie prefix
2022-09-10 13:50:49 +01:00
729c52df2f
Merge pull request #418 from jonnybarnes/develop
MTM JWT and Cookie fixes
2022-09-10 13:31:24 +01:00
ac19c02e76
Merge pull request #380 from jonnybarnes/develop
MTM More link fixes
2022-08-21 09:00:38 +01:00
0289f9f0b0
Merge pull request #378 from jonnybarnes/develop
MTM Fixing links
2022-08-20 15:31:34 +01:00
14a10472a6
Merge pull request #365 from jonnybarnes/develop
MTM Add tagged bookmarks page
2022-08-14 18:03:30 +01:00
b74bdd01ef
Merge pull request #362 from jonnybarnes/develop
MTM: Fix some links
2022-08-13 20:30:08 +01:00
9c059f822f
Merge pull request #293 from jonnybarnes/develop
MTM Mentions Improvements
2022-07-09 12:03:07 +01:00
75e369992d
Merge pull request #268 from jonnybarnes/develop
MTM Deploy tags
2022-06-04 17:24:37 +01:00
a18f7b2d03
Merge pull request #265 from jonnybarnes/develop
MTM Dependency updates and HTML Sanitizer
2022-06-04 11:16:23 +01:00
7d1738964a
Merge pull request #261 from jonnybarnes/develop
MTM: Laravel 9
2022-05-15 17:20:12 +01:00
77004ec7d4
Merge pull request #246 from jonnybarnes/develop
MTM: Syndication links style fix
2022-01-08 21:25:14 +00:00
3f891b580c
Merge pull request #244 from jonnybarnes/develop
MTM: Various dependency updates and new deployment code
2022-01-08 19:58:35 +00:00
38f3244e31
Merge pull request #220 from jonnybarnes/develop
MTM: PHP8 support, and PostCSS Webpack work
2020-12-31 14:55:46 +00:00
650d54292e
Merge pull request #215 from jonnybarnes/develop
MTM: Get main branch up to date
2020-11-08 11:14:29 +00:00
c40548be73
Merge pull request #114 from jonnybarnes/develop
MTM: recent work on Horizon
2019-03-23 16:10:06 +00:00
270 changed files with 19237 additions and 11453 deletions

View file

@ -1,5 +1,9 @@
# EditorConfig is awesome: http://EditorConfig.org
# top-most EditorConfig file
root = true root = true
# Unix-style newlines with a newline ending every file
[*] [*]
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
@ -8,8 +12,10 @@ indent_style = space
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.{js,css}] # Tab indentation
indent_size = 2 [Makefile]
indent_style = tab
tab_width = 4
[*.md] [*.md]
trim_trailing_whitespace = false trim_trailing_whitespace = false

14
.env.dusk.testing Normal file
View file

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

View file

@ -2,22 +2,11 @@ APP_NAME=Laravel
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=https://example.com APP_URL=https://example.com
APP_LONGURL=example.com
APP_LOCALE=en APP_SHORTURL=examp.le
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
@ -28,30 +17,22 @@ DB_DATABASE=laravel
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD= DB_PASSWORD=
SESSION_DRIVER=database BROADCAST_DRIVER=log
SESSION_LIFETIME=120 CACHE_DRIVER=file
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=sync
SESSION_DRIVER=file
CACHE_STORE=database SESSION_LIFETIME=120
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1 REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=smtp
MAIL_SCHEME=null MAIL_HOST=mailhog
MAIL_HOST=127.0.0.1 MAIL_PORT=1025
MAIL_PORT=2525
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null
@ -64,7 +45,19 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET= AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
ADMIN_USER=admin# pick something better, this is used for `/admin` ADMIN_USER=admin# pick something better, this is used for `/admin`
ADMIN_PASS=password ADMIN_PASS=password
@ -78,13 +71,19 @@ TWITTER_ACCESS_TOKEN_SECRET=
SCOUT_DRIVER=database SCOUT_DRIVER=database
SCOUT_QUEUE=false SCOUT_QUEUE=false
PIWIK=false
PIWIK_ID=1
PIWIK_URL=https://analytics.jmb.lv/piwik.php
FATHOM_ID=
APP_TIMEZONE=UTC
APP_LANG=en
APP_LOG=daily
SESSION_SECURE_COOKIE=true SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=strict
LOG_SLACK_WEBHOOK_URL= LOG_SLACK_WEBHOOK_URL=
FLARE_KEY= FONT_LINK=
IGNITION_OPEN_AI_KEY=
BRIDGY_MASTODON_TOKEN= BRIDGY_MASTODON_TOKEN=

67
.env.github Normal file
View file

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

24
.eslintrc.yml Normal file
View file

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

12
.gitattributes vendored
View file

@ -1,7 +1,5 @@
* text=auto eol=lf * text=auto
*.css linguist-vendored
*.blade.php diff=html *.scss linguist-vendored
*.css diff=css *.js linguist-vendored
*.html diff=html CHANGELOG.md export-ignore
*.md diff=markdown
*.php diff=php

12
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"

142
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,142 @@
name: Deploy
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment: Hetzner
env:
repository: 'jonnybarnes/jonnybarnes.uk'
newReleaseName: '${{ github.run_id }}'
steps:
- name: 🌍 Set Environment Variables
run: |
echo "releasesDir=${{ secrets.DEPLOYMENT_BASE_DIR }}/releases" >> $GITHUB_ENV
echo "persistentDir=${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent" >> $GITHUB_ENV
echo "currentDir=${{ secrets.DEPLOYMENT_BASE_DIR }}/current" >> $GITHUB_ENV
- name: 🌎 Set Environment Variables Part 2
run: |
echo "newReleaseDir=${{ env.releasesDir }}/${{ env.newReleaseName }}" >> $GITHUB_ENV
- name: 🔄 Clone Repository
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOYMENT_HOST }}
port: ${{ secrets.DEPLOYMENT_PORT }}
username: ${{ secrets.DEPLOYMENT_USER }}
key: ${{ secrets.DEPLOYMENT_KEY }}
script: |
[ -d ${{ env.releasesDir }} ] || mkdir ${{ env.releasesDir }}
[ -d ${{ env.persistentDir }} ] || mkdir ${{ env.persistentDir }}
[ -d ${{ env.persistentDir }}/storage ] || mkdir ${{ env.persistentDir }}/storage
cd ${{ env.releasesDir }}
# Create new release directory
mkdir ${{ env.newReleaseDir }}
# Clone app
git clone --depth 1 --branch ${{ github.ref_name }} https://github.com/${{ env.repository }} ${{ env.newReleaseName }}
# Mark release
cd ${{ env.newReleaseDir }}
echo "${{ env.newReleaseName }}" > public/release-name.txt
# Fix cache directory permissions
sudo chown -R ${{ secrets.HTTP_USER }}:${{ secrets.HTTP_USER }} bootstrap/cache
- name: 🎵 Run Composer
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOYMENT_HOST }}
port: ${{ secrets.DEPLOYMENT_PORT }}
username: ${{ secrets.DEPLOYMENT_USER }}
key: ${{ secrets.DEPLOYMENT_KEY }}
script: |
cd ${{ env.newReleaseDir }}
composer install --prefer-dist --no-scripts --no-dev --no-progress --optimize-autoloader --quiet --no-interaction
- name: 🔗 Update Symlinks
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOYMENT_HOST }}
port: ${{ secrets.DEPLOYMENT_PORT }}
username: ${{ secrets.DEPLOYMENT_USER }}
key: ${{ secrets.DEPLOYMENT_KEY }}
script: |
# Import the environment config
cd ${{ env.newReleaseDir }};
ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/.env .env;
# Remove the storage directory and replace with persistent data
rm -rf ${{ env.newReleaseDir }}/storage;
cd ${{ env.newReleaseDir }};
ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/storage storage;
# Remove the public/profile-images directory and replace with persistent data
rm -rf ${{ env.newReleaseDir }}/public/assets/profile-images;
cd ${{ env.newReleaseDir }};
ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/profile-images public/assets/profile-images;
# Add the persistent files data
cd ${{ env.newReleaseDir }};
ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/files public/files;
# Add the persistent fonts data
cd ${{ env.newReleaseDir }};
ln -nfs ${{ secrets.DEPLOYMENT_BASE_DIR }}/persistent/fonts public/fonts;
- name: ✨ Optimize Installation
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOYMENT_HOST }}
port: ${{ secrets.DEPLOYMENT_PORT }}
username: ${{ secrets.DEPLOYMENT_USER }}
key: ${{ secrets.DEPLOYMENT_KEY }}
script: |
cd ${{ env.newReleaseDir }};
sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan clear-compiled;
- name: 🙈 Migrate database
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOYMENT_HOST }}
port: ${{ secrets.DEPLOYMENT_PORT }}
username: ${{ secrets.DEPLOYMENT_USER }}
key: ${{ secrets.DEPLOYMENT_KEY }}
script: |
cd ${{ env.newReleaseDir }}
sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan migrate --force
- name: 🙏 Bless release
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOYMENT_HOST }}
port: ${{ secrets.DEPLOYMENT_PORT }}
username: ${{ secrets.DEPLOYMENT_USER }}
key: ${{ secrets.DEPLOYMENT_KEY }}
script: |
ln -nfs ${{ env.newReleaseDir }} ${{ env.currentDir }};
cd ${{ env.newReleaseDir }}
sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan horizon:terminate
sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan config:cache
sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan event:cache
sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan route:cache
sudo runuser -u ${{ secrets.HTTP_USER }} -- php artisan view:cache
sudo systemctl restart php-fpm.service
sudo systemctl restart jbuk-horizon.service
- name: 🚾 Clean up old releases
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOYMENT_HOST }}
port: ${{ secrets.DEPLOYMENT_PORT }}
username: ${{ secrets.DEPLOYMENT_USER }}
key: ${{ secrets.DEPLOYMENT_KEY }}
script: |
fd '.+' ${{ env.releasesDir }} -d 1 | head -n -3 | xargs -d "\n" -I'{}' sudo chown -R ${{ secrets.DEPLOYMENT_USER }}:${{ secrets.DEPLOYMENT_USER }} {}/bootstrap/cache;
fd '.+' ${{ env.releasesDir }} -d 1 | head -n -3 | xargs -d "\n" -I'{}' rm -rf {};

68
.github/workflows/phpunit.yml vendored Normal file
View file

@ -0,0 +1,68 @@
name: PHP Unit
on:
pull_request:
jobs:
phpunit:
runs-on: ubuntu-latest
name: PHPUnit test suite
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: jbukdev_testing
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Node and dependencies
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- run: npm ci
- name: Setup PHP with pecl extensions
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: phpredis,imagick
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.github', '.env');"
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install Composer Dependencies
run: composer install --quiet --no-ansi --no-interaction --no-progress
- name: Generate Key
run: php artisan key:generate
- name: Setup Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Setup Database
run: php artisan migrate
- name: Execute PHPUnit Tests
run: vendor/bin/phpunit

38
.github/workflows/pint.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Laravel Pint
on:
pull_request:
jobs:
pint:
runs-on: ubuntu-latest
name: Laravel Pint
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup PHP with pecl extensions
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install Composer Dependencies
run: composer install --quiet --no-ansi --no-interaction --no-progress
- name: Check Files with Laravel Pint
run: vendor/bin/pint --test

24
.gitignore vendored
View file

@ -1,24 +1,22 @@
/.phpunit.cache
/node_modules /node_modules
/public/build
/public/coverage
/public/hot
/public/files
/public/storage
/storage/*.key /storage/*.key
/vendor /vendor
.env .env
.env.backup
.env.production
.phpunit.result.cache .phpunit.result.cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
auth.json
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
/.fleet
/.idea /.idea
/.vscode /lsp
.phpstorm.meta.php
_ide_helper.php
ray.php ray.php
/public/gpg.key # Custom paths in /public
/public/assets/img/favicon.png /public/coverage
/public/hot
/public/storage
/public/fonts
/public/files
/public/keybase.txt
/public/assets/*.map

8
.styleci.yml Normal file
View file

@ -0,0 +1,8 @@
preset: laravel
disabled:
- concat_without_spaces
- single_import_per_statement
finder:
path: app/

View file

@ -1,3 +1,7 @@
{ {
"extends": ["stylelint-config-standard"] "extends": ["stylelint-config-standard"],
"rules": {
"indentation": 4,
"import-notation": "string"
}
} }

View file

@ -8,7 +8,7 @@ use League\CommonMark\Extension\Mention\Generator\MentionGeneratorInterface;
use League\CommonMark\Extension\Mention\Mention; use League\CommonMark\Extension\Mention\Mention;
use League\CommonMark\Node\Inline\AbstractInline; use League\CommonMark\Node\Inline\AbstractInline;
class MentionGenerator implements MentionGeneratorInterface class ContactMentionGenerator implements MentionGeneratorInterface
{ {
public function generateMention(Mention $mention): ?AbstractInline public function generateMention(Mention $mention): ?AbstractInline
{ {

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\CommonMark\Renderers;
use App\Models\Contact;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
class ContactMentionRenderer implements NodeRendererInterface
{
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
$contact = Contact::where('nick', $node->getIdentifier())->first();
if ($contact === null) {
return '<a href="https://twitter.com/' . $node->getIdentifier() . '">@' . $node->getIdentifier() . '</a>';
}
return trim(view('templates.mini-hcard', ['contact' => $contact])->render());
}
}

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\CommonMark\Renderers;
use App\Models\Contact;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
class MentionRenderer implements NodeRendererInterface
{
public function render(Node $node, ChildNodeRendererInterface $childRenderer): HtmlElement|string
{
$contact = Contact::where('nick', $node->getIdentifier())->first();
// If we have a contact, render a mini-hcard
if ($contact) {
// rendering a blade template to a string, so cant 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);
}
}

View file

@ -1,69 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Media;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class CopyMediaToLocal extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:copy-media-to-local';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Copy any historic media saved to S3 to the local filesystem';
/**
* Execute the console command.
*/
public function handle()
{
// Load all the Media records
$media = Media::all();
// Loop through each media record and copy the file from S3 to the local filesystem
foreach ($media as $mediaItem) {
$filename = $mediaItem->path;
$this->info('Processing: ' . $filename);
// If the file is already saved locally skip to next one
if (Storage::disk('local')->exists('public/' . $filename)) {
$this->info('File already exists locally, skipping');
continue;
}
// Copy the file from S3 to the local filesystem
if (! Storage::disk('s3')->exists($filename)) {
$this->error('File does not exist on S3');
continue;
}
$contents = Storage::disk('s3')->get($filename);
Storage::disk('local')->put('public/' . $filename, $contents);
// Copy -medium and -small versions if they exist
$filenameParts = explode('.', $filename);
$extension = array_pop($filenameParts);
$basename = trim(implode('.', $filenameParts), '.');
$mediumFilename = $basename . '-medium.' . $extension;
$smallFilename = $basename . '-small.' . $extension;
if (Storage::disk('s3')->exists($mediumFilename)) {
Storage::disk('local')->put('public/' . $mediumFilename, Storage::disk('s3')->get($mediumFilename));
}
if (Storage::disk('s3')->exists($smallFilename)) {
Storage::disk('local')->put('public/' . $smallFilename, Storage::disk('s3')->get($smallFilename));
}
}
}
}

View file

@ -34,7 +34,7 @@ class ParseCachedWebMentions extends Command
{ {
$htmlFiles = $filesystem->allFiles(storage_path() . '/HTML'); $htmlFiles = $filesystem->allFiles(storage_path() . '/HTML');
foreach ($htmlFiles as $file) { foreach ($htmlFiles as $file) {
if ($file->getExtension() !== 'backup') { // we dont want to parse `.backup` files if ($file->getExtension() !== 'backup') { //we dont want to parse `.backup` files
$filepath = $file->getPathname(); $filepath = $file->getPathname();
$this->info('Loading HTML from: ' . $filepath); $this->info('Loading HTML from: ' . $filepath);
$html = $filesystem->get($filepath); $html = $filesystem->get($filepath);

View file

@ -1,36 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Note;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class UpdateWebmentionsRelationship extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webmentions:update-model-relationship';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Update webmentions to relate to the correct note model class';
/**
* Execute the console command.
*/
public function handle()
{
DB::table('webmentions')
->where('commentable_type', '=', 'App\Model\Note')
->update(['commentable_type' => Note::class]);
$this->info('All webmentions updated to relate to the correct note model class');
}
}

View file

@ -7,8 +7,20 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
{ {
/**
* The Artisan commands provided by your application.
*
* @var array<int, string>
*/
protected $commands = [
Commands\ParseCachedWebMentions::class,
Commands\ReDownloadWebMentions::class,
];
/** /**
* Define the application's command schedule. * Define the application's command schedule.
*
* @codeCoverageIgnore
*/ */
protected function schedule(Schedule $schedule): void protected function schedule(Schedule $schedule): void
{ {
@ -21,7 +33,7 @@ class Kernel extends ConsoleKernel
*/ */
protected function commands(): void protected function commands(): void
{ {
$this->load(__DIR__.'/Commands'); $this->load(__DIR__ . '/Commands');
require base_path('routes/console.php'); require base_path('routes/console.php');
} }

View file

@ -2,18 +2,90 @@
namespace App\Exceptions; namespace App\Exceptions;
use Exception;
use GuzzleHttp\Client;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; 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; use Throwable;
/**
* @codeCoverageIgnore
*/
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
{ {
/** /**
* Register the exception handling callbacks for the application. * A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
*/ */
public function register(): void protected $dontReport = [
NotFoundHttpException::class,
ModelNotFoundException::class,
];
/**
* Report or log an exception.
*
* This is a great spot to send exceptions to Sentry, Bugsnag, etc.
*
* @throws Exception
* @throws Throwable
*/
public function report(Throwable $e): void
{ {
$this->reportable(function (Throwable $_e) { parent::report($e);
//
}); if (config('logging.slack') && $this->shouldReport($e)) {
$guzzle = new Client([
'headers' => [
'Content-Type' => 'application/json',
],
]);
$exceptionName = get_class($e) ?? 'Unknown Exception';
$title = $exceptionName . ': ' . $e->getMessage();
$guzzle->post(
config('logging.slack'),
[
'body' => json_encode([
'attachments' => [[
'fallback' => 'There was an exception.',
'pretext' => 'There was an exception.',
'color' => '#d00000',
'author_name' => app()->environment(),
'author_link' => config('app.url'),
'fields' => [[
'title' => $title,
'value' => request()->method() . ' ' . request()->fullUrl(),
]],
'ts' => time(),
]],
]),
]
);
}
}
/**
* Render an exception into an HTTP response.
*
* @param Request $request
* @return Response
*
* @throws Throwable
*/
public function render($request, Throwable $throwable)
{
if ($throwable instanceof TokenMismatchException) {
Route::getRoutes()->match($request);
}
return parent::render($request, $throwable);
} }
} }

View file

@ -2,4 +2,6 @@
namespace App\Exceptions; namespace App\Exceptions;
class InternetArchiveException extends \Exception {} class InternetArchiveException extends \Exception
{
}

View file

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

View file

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

View file

@ -6,5 +6,5 @@ use Exception;
class RemoteContentNotFoundException extends Exception class RemoteContentNotFoundException extends Exception
{ {
// used when guzzle cant find the remote content //used when guzzle cant find the remote content
} }

View file

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
class TwitterContentException extends \Exception
{
}

View file

@ -27,7 +27,7 @@ class ArticlesController extends Controller
public function store(): RedirectResponse public function store(): RedirectResponse
{ {
// if a `.md` is attached use that for the main content. //if a `.md` is attached use that for the main content.
if (request()->hasFile('article')) { if (request()->hasFile('article')) {
$file = request()->file('article')->openFile(); $file = request()->file('article')->openFile();
$content = $file->fread($file->getSize()); $content = $file->fread($file->getSize());

View file

@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\MicropubClient; use App\Models\MicropubClient;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
class ClientsController extends Controller class ClientsController extends Controller

View file

@ -9,6 +9,7 @@ use App\Models\Contact;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\View\View; use Illuminate\View\View;
@ -37,7 +38,7 @@ class ContactsController extends Controller
*/ */
public function store(): RedirectResponse public function store(): RedirectResponse
{ {
$contact = new Contact; $contact = new Contact();
$contact->name = request()->input('name'); $contact->name = request()->input('name');
$contact->nick = request()->input('nick'); $contact->nick = request()->input('nick');
$contact->homepage = request()->input('homepage'); $contact->homepage = request()->input('homepage');
@ -76,7 +77,7 @@ class ContactsController extends Controller
if (request()->hasFile('avatar') && (request()->input('homepage') != '')) { if (request()->hasFile('avatar') && (request()->input('homepage') != '')) {
$dir = parse_url(request()->input('homepage'), PHP_URL_HOST); $dir = parse_url(request()->input('homepage'), PHP_URL_HOST);
$destination = public_path() . '/assets/profile-images/' . $dir; $destination = public_path() . '/assets/profile-images/' . $dir;
$filesystem = new Filesystem; $filesystem = new Filesystem();
if ($filesystem->isDirectory($destination) === false) { if ($filesystem->isDirectory($destination) === false) {
$filesystem->makeDirectory($destination); $filesystem->makeDirectory($destination);
} }
@ -136,7 +137,7 @@ class ContactsController extends Controller
} }
if ($avatar !== null) { if ($avatar !== null) {
$directory = public_path() . '/assets/profile-images/' . parse_url($contact->homepage, PHP_URL_HOST); $directory = public_path() . '/assets/profile-images/' . parse_url($contact->homepage, PHP_URL_HOST);
$filesystem = new Filesystem; $filesystem = new Filesystem();
if ($filesystem->isDirectory($directory) === false) { if ($filesystem->isDirectory($directory) === false) {
$filesystem->makeDirectory($directory); $filesystem->makeDirectory($directory);
} }

View file

@ -64,7 +64,7 @@ class NotesController extends Controller
*/ */
public function update(int $noteId): RedirectResponse public function update(int $noteId): RedirectResponse
{ {
// update note data //update note data
$note = Note::findOrFail($noteId); $note = Note::findOrFail($noteId);
$note->note = request()->input('content'); $note->note = request()->input('content');
$note->in_reply_to = request()->input('in-reply-to'); $note->in_reply_to = request()->input('in-reply-to');

View file

@ -1,326 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Passkey;
use App\Models\User;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\ECDSA\ES256;
use Cose\Algorithm\Signature\EdDSA\Ed25519;
use Cose\Algorithm\Signature\RSA\RS256;
use Cose\Algorithms;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Random\RandomException;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\Exception\WebauthnException;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
class PasskeysController extends Controller
{
public function index(): View
{
/** @var User $user */
$user = auth()->user();
$passkeys = $user->passkey;
return view('admin.passkeys.index', compact('passkeys'));
}
/**
* @throws RandomException
* @throws \JsonException
*/
public function getCreateOptions(Request $request): JsonResponse
{
/** @var User $user */
$user = auth()->user();
// RP Entity i.e. the application
$rpEntity = PublicKeyCredentialRpEntity::create(
name: config('app.name'),
id: config('app.url'),
);
// User Entity
$userEntity = PublicKeyCredentialUserEntity::create(
name: $user->name,
id: (string) $user->id,
displayName: $user->name,
);
// Challenge
$challenge = random_bytes(16);
// List of supported public key parameters
$pubKeyCredParams = collect([
Algorithms::COSE_ALGORITHM_EDDSA,
Algorithms::COSE_ALGORITHM_ES256,
Algorithms::COSE_ALGORITHM_RS256,
])->map(
fn ($algorithm) => PublicKeyCredentialParameters::create('public-key', $algorithm)
)->toArray();
$authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create(
userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED,
);
$publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::create(
rp: $rpEntity,
user: $userEntity,
challenge: $challenge,
pubKeyCredParams: $pubKeyCredParams,
authenticatorSelection: $authenticatorSelectionCriteria,
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE
);
$attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$webauthnSerializerFactory = new WebauthnSerializerFactory(
attestationStatementSupportManager: $attestationStatementSupportManager
);
$webauthnSerializer = $webauthnSerializerFactory->create();
$publicKeyCredentialCreationOptions = $webauthnSerializer->serialize(
data: $publicKeyCredentialCreationOptions,
format: 'json'
);
$request->session()->put('create_options', $publicKeyCredentialCreationOptions);
return JsonResponse::fromJsonString($publicKeyCredentialCreationOptions);
}
/**
* @throws Throwable
* @throws WebauthnException
* @throws \JsonException
*/
public function create(Request $request): JsonResponse
{
/** @var User $user */
$user = auth()->user();
$publicKeyCredentialCreationOptionsData = session('create_options');
// Unset session data to mitigate replay attacks
$request->session()->forget('create_options');
if (empty($publicKeyCredentialCreationOptionsData)) {
throw new WebAuthnException('No public key credential request options found');
}
$attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$webauthnSerializerFactory = new WebauthnSerializerFactory(
attestationStatementSupportManager: $attestationStatementSupportManager
);
$webauthnSerializer = $webauthnSerializerFactory->create();
$publicKeyCredential = $webauthnSerializer->deserialize(
json_encode($request->all(), JSON_THROW_ON_ERROR),
PublicKeyCredential::class,
'json'
);
if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
throw new WebAuthnException('Invalid response type');
}
$algorithmManager = new Manager;
$algorithmManager->add(new Ed25519);
$algorithmManager->add(new ES256);
$algorithmManager->add(new RS256);
$ceremonyStepManagerFactory = new CeremonyStepManagerFactory;
$ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager);
$ceremonyStepManagerFactory->setAttestationStatementSupportManager(
$attestationStatementSupportManager
);
$ceremonyStepManagerFactory->setExtensionOutputCheckerHandler(
ExtensionOutputCheckerHandler::create()
);
$allowedOrigins = [];
if (App::environment('local', 'development')) {
$allowedOrigins = [config('app.url')];
}
$ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins);
$authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create(
ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony()
);
$publicKeyCredentialCreationOptions = $webauthnSerializer->deserialize(
$publicKeyCredentialCreationOptionsData,
PublicKeyCredentialCreationOptions::class,
'json'
);
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
authenticatorAttestationResponse: $publicKeyCredential->response,
publicKeyCredentialCreationOptions: $publicKeyCredentialCreationOptions,
host: config('app.url')
);
$user->passkey()->create([
'passkey_id' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->publicKeyCredentialId),
'passkey' => json_encode($publicKeyCredentialSource, JSON_THROW_ON_ERROR),
]);
return response()->json([
'success' => true,
'message' => 'Passkey created successfully',
]);
}
/**
* @throws RandomException
* @throws \JsonException
*/
public function getRequestOptions(Request $request): JsonResponse
{
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create(
challenge: random_bytes(16),
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED
);
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
$factory = new WebauthnSerializerFactory(
attestationStatementSupportManager: $attestationStatementSupportManager
);
$serializer = $factory->create();
$publicKeyCredentialRequestOptions = $serializer->serialize(data: $publicKeyCredentialRequestOptions, format: 'json');
$request->session()->put('request_options', $publicKeyCredentialRequestOptions);
return JsonResponse::fromJsonString($publicKeyCredentialRequestOptions);
}
/**
* @throws \JsonException
*/
public function login(Request $request): JsonResponse
{
$requestOptions = session('request_options');
$request->session()->forget('request_options');
if (empty($requestOptions)) {
return response()->json([
'success' => false,
'message' => 'No request options found',
], 400);
}
$attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$webauthnSerializerFactory = new WebauthnSerializerFactory(
attestationStatementSupportManager: $attestationStatementSupportManager
);
$webauthnSerializer = $webauthnSerializerFactory->create();
$publicKeyCredential = $webauthnSerializer->deserialize(
json_encode($request->all(), JSON_THROW_ON_ERROR),
PublicKeyCredential::class,
'json'
);
if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
return response()->json([
'success' => false,
'message' => 'Invalid response type',
], 400);
}
$passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id);
if (! $passkey) {
return response()->json([
'success' => false,
'message' => 'Passkey not found',
], 404);
}
$publicKeyCredentialSource = $webauthnSerializer->deserialize(
$passkey->passkey,
PublicKeyCredentialSource::class,
'json'
);
$algorithmManager = new Manager;
$algorithmManager->add(new Ed25519);
$algorithmManager->add(new ES256);
$algorithmManager->add(new RS256);
$attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$ceremonyStepManagerFactory = new CeremonyStepManagerFactory;
$ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager);
$ceremonyStepManagerFactory->setAttestationStatementSupportManager(
$attestationStatementSupportManager
);
$ceremonyStepManagerFactory->setExtensionOutputCheckerHandler(
ExtensionOutputCheckerHandler::create()
);
$allowedOrigins = [];
if (App::environment('local', 'development')) {
$allowedOrigins = [config('app.url')];
}
$ceremonyStepManagerFactory->setAllowedOrigins($allowedOrigins);
$authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create(
ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony()
);
$publicKeyCredentialRequestOptions = $webauthnSerializer->deserialize(
$requestOptions,
PublicKeyCredentialRequestOptions::class,
'json'
);
try {
$authenticatorAssertionResponseValidator->check(
publicKeyCredentialSource: $publicKeyCredentialSource,
authenticatorAssertionResponse: $publicKeyCredential->response,
publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions,
host: config('app.url'),
userHandle: null,
);
} catch (Throwable) {
return response()->json([
'success' => false,
'message' => 'Passkey could not be verified',
], 500);
}
$user = User::find($passkey->user_id);
Auth::login($user);
return response()->json([
'success' => true,
'message' => 'Passkey verified successfully',
]);
}
}

View file

@ -15,7 +15,7 @@ class ArticlesController extends Controller
/** /**
* Show all articles (with pagination). * Show all articles (with pagination).
*/ */
public function index(?int $year = null, ?int $month = null): View public function index(int $year = null, int $month = null): View
{ {
$articles = Article::where('published', '1') $articles = Article::where('published', '1')
->date($year, $month) ->date($year, $month)

View file

@ -31,7 +31,7 @@ class AuthController extends Controller
$credentials = $request->only('name', 'password'); $credentials = $request->only('name', 'password');
if (Auth::attempt($credentials, true)) { if (Auth::attempt($credentials, true)) {
return redirect()->intended('/admin'); return redirect()->intended('/');
} }
return redirect()->route('login'); return redirect()->route('login');

View file

@ -15,7 +15,7 @@ class ContactsController extends Controller
*/ */
public function index(): View public function index(): View
{ {
$filesystem = new Filesystem; $filesystem = new Filesystem();
$contacts = Contact::all(); $contacts = Contact::all();
foreach ($contacts as $contact) { foreach ($contacts as $contact) {
$contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST); $contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST);
@ -37,7 +37,7 @@ class ContactsController extends Controller
$contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST); $contact->homepageHost = parse_url($contact->homepage, PHP_URL_HOST);
$file = public_path() . '/assets/profile-images/' . $contact->homepageHost . '/image'; $file = public_path() . '/assets/profile-images/' . $contact->homepageHost . '/image';
$filesystem = new Filesystem; $filesystem = new Filesystem();
$image = ($filesystem->exists($file)) ? $image = ($filesystem->exists($file)) ?
'/assets/profile-images/' . $contact->homepageHost . '/image' '/assets/profile-images/' . $contact->homepageHost . '/image'
: :

View file

@ -2,7 +2,14 @@
namespace App\Http\Controllers; 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;
} }

View file

@ -70,16 +70,10 @@ class FeedsController extends Controller
{ {
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$data = [ $data = [
'version' => 'https://jsonfeed.org/version/1.1', 'version' => 'https://jsonfeed.org/version/1',
'title' => 'The JSON Feed for ' . config('user.display_name') . 's blog', 'title' => 'The JSON Feed for ' . config('app.display_name') . 's blog',
'home_page_url' => config('app.url') . '/blog', 'home_page_url' => config('app.url') . '/blog',
'feed_url' => config('app.url') . '/blog/feed.json', 'feed_url' => config('app.url') . '/blog/feed.json',
'authors' => [
[
'name' => config('user.display_name'),
'url' => config('app.url'),
],
],
'items' => [], 'items' => [],
]; ];
@ -91,6 +85,9 @@ class FeedsController extends Controller
'content_html' => $article->main, 'content_html' => $article->main,
'date_published' => $article->created_at->tz('UTC')->toRfc3339String(), 'date_published' => $article->created_at->tz('UTC')->toRfc3339String(),
'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(), 'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(),
'author' => [
'name' => config('app.display_name'),
],
]; ];
} }
@ -102,32 +99,26 @@ class FeedsController extends Controller
*/ */
public function notesJson(): array public function notesJson(): array
{ {
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); $notes = Note::latest()->with('media')->take(20)->get();
$data = [ $data = [
'version' => 'https://jsonfeed.org/version/1.1', 'version' => 'https://jsonfeed.org/version/1',
'title' => 'The JSON Feed for ' . config('user.display_name') . 's notes', 'title' => 'The JSON Feed for ' . config('app.display_name') . 's notes',
'home_page_url' => config('app.url') . '/notes', 'home_page_url' => config('app.url') . '/notes',
'feed_url' => config('app.url') . '/notes/feed.json', 'feed_url' => config('app.url') . '/notes/feed.json',
'authors' => [
[
'name' => config('user.display_name'),
'url' => config('app.url'),
],
],
'items' => [], 'items' => [],
]; ];
foreach ($notes as $key => $note) { foreach ($notes as $key => $note) {
$data['items'][$key] = [ $data['items'][$key] = [
'id' => $note->uri, 'id' => $note->longurl,
'url' => $note->uri, 'url' => $note->longurl,
'content_text' => $note->content, 'content_html' => $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(),
'author' => [
'name' => config('app.display_name'),
],
]; ];
if ($note->tags->count() > 0) {
$data['items'][$key]['tags'] = implode(',', $note->tags->pluck('tag')->toArray());
}
} }
return $data; return $data;
@ -160,8 +151,8 @@ class FeedsController extends Controller
'url' => url('/blog'), 'url' => url('/blog'),
'author' => [ 'author' => [
'type' => 'card', 'type' => 'card',
'name' => config('user.display_name'), 'name' => config('user.displayname'),
'url' => config('app.url'), 'url' => config('app.longurl'),
], ],
'children' => $items, 'children' => $items,
], 200, [ ], 200, [
@ -180,8 +171,8 @@ class FeedsController extends Controller
$items[] = [ $items[] = [
'type' => 'entry', 'type' => 'entry',
'published' => $note->created_at, 'published' => $note->created_at,
'uid' => $note->uri, 'uid' => $note->longurl,
'url' => $note->uri, 'url' => $note->longurl,
'content' => [ 'content' => [
'text' => $note->getRawOriginal('note'), 'text' => $note->getRawOriginal('note'),
'html' => $note->note, 'html' => $note->note,
@ -196,8 +187,8 @@ class FeedsController extends Controller
'url' => url('/notes'), 'url' => url('/notes'),
'author' => [ 'author' => [
'type' => 'card', 'type' => 'card',
'name' => config('user.display_name'), 'name' => config('user.displayname'),
'url' => config('app.url'), 'url' => config('app.longurl'),
], ],
'children' => $items, 'children' => $items,
], 200, [ ], 200, [

View file

@ -7,6 +7,8 @@ use App\Models\Bio;
use App\Models\Bookmark; use App\Models\Bookmark;
use App\Models\Like; use App\Models\Like;
use App\Models\Note; use App\Models\Note;
use App\Services\ActivityStreamsService;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\View\View; use Illuminate\View\View;
@ -15,19 +17,15 @@ class FrontPageController extends Controller
/** /**
* Show all the recent activity. * Show all the recent activity.
*/ */
public function index(): Response|View public function index(Request $request): Response|View
{ {
$notes = Note::latest()->with(['media', 'client', 'place'])->withCount(['webmentions AS replies' => function ($query) { if ($request->wantsActivityStream()) {
$query->where('type', 'in-reply-to'); return (new ActivityStreamsService())->siteOwnerResponse();
}]) }
->withCount(['webmentions AS likes' => function ($query) {
$query->where('type', 'like-of'); $notes = Note::latest()->with(['media', 'client', 'place'])->get();
}])
->withCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}])->get();
$articles = Article::latest()->get(); $articles = Article::latest()->get();
$bookmarks = Bookmark::latest()->with('tags')->get(); $bookmarks = Bookmark::latest()->get();
$likes = Like::latest()->get(); $likes = Like::latest()->get();
$items = collect($notes) $items = collect($notes)

View file

@ -1,327 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\TokenService;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View;
use Random\RandomException;
use SodiumException;
class IndieAuthController extends Controller
{
public function indieAuthMetadataEndpoint(): JsonResponse
{
return response()->json([
'issuer' => config('app.url'),
'authorization_endpoint' => route('indieauth.start'),
'token_endpoint' => route('indieauth.token'),
'code_challenge_methods_supported' => ['S256'],
// 'introspection_endpoint' => route('indieauth.introspection'),
// 'introspection_endpoint_auth_methods_supported' => ['none'],
]);
}
/**
* Process a GET request to the IndieAuth endpoint.
*
* This is the first step in the IndieAuth flow, where the client app sends the user to the IndieAuth endpoint.
*/
public function start(Request $request): View
{
// First check all required params are present
$validator = Validator::make($request->all(), [
'response_type' => 'required:string',
'client_id' => 'required',
'redirect_uri' => 'required',
'state' => 'required',
'code_challenge' => 'required:string',
'code_challenge_method' => 'required:string',
], [
'response_type' => 'response_type is required',
'client_id.required' => 'client_id is required to display which app is asking for authentication',
'redirect_uri.required' => 'redirect_uri is required so we can progress successful requests',
'state.required' => 'state is required',
'code_challenge.required' => 'code_challenge is required',
'code_challenge_method.required' => 'code_challenge_method is required',
]);
if ($validator->fails()) {
return view('indieauth.error')->withErrors($validator);
}
if ($request->get('response_type') !== 'code') {
return view('indieauth.error')->withErrors(['response_type' => 'only a response_type of "code" is supported']);
}
if (mb_strtoupper($request->get('code_challenge_method')) !== 'S256') {
return view('indieauth.error')->withErrors(['code_challenge_method' => 'only a code_challenge_method of "S256" is supported']);
}
if (! $this->isValidRedirectUri($request->get('client_id'), $request->get('redirect_uri'))) {
return view('indieauth.error')->withErrors(['redirect_uri' => 'redirect_uri is not valid for this client_id']);
}
$scopes = $request->get('scope', '');
$scopes = explode(' ', $scopes);
return view('indieauth.start', [
'me' => $request->get('me'),
'client_id' => $request->get('client_id'),
'redirect_uri' => $request->get('redirect_uri'),
'state' => $request->get('state'),
'scopes' => $scopes,
'code_challenge' => $request->get('code_challenge'),
'code_challenge_method' => $request->get('code_challenge_method'),
]);
}
/**
* Confirm an IndieAuth approval request.
*
* Generates an auth code and redirects the user back to the client app.
*
* @throws RandomException
*/
public function confirm(Request $request): RedirectResponse
{
$authCode = bin2hex(random_bytes(16));
$cacheKey = hash('xxh3', $request->get('client_id'));
$indieAuthRequestData = [
'code_challenge' => $request->get('code_challenge'),
'code_challenge_method' => $request->get('code_challenge_method'),
'client_id' => $request->get('client_id'),
'redirect_uri' => $request->get('redirect_uri'),
'auth_code' => $authCode,
'scope' => implode(' ', $request->get('scope', '')),
];
Cache::put($cacheKey, $indieAuthRequestData, now()->addMinutes(10));
$redirectUri = new Uri($request->get('redirect_uri'));
$redirectUri = Uri::withQueryValues($redirectUri, [
'code' => $authCode,
'state' => $request->get('state'),
'iss' => config('app.url'),
]);
return redirect()->away($redirectUri);
}
/**
* Process a POST request to the IndieAuth auth endpoint.
*
* This is one possible second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth
* endpoint. As it is to the auth endpoint we return profile information. A similar request can be made to the token
* endpoint to get an access token.
*/
public function processCodeExchange(Request $request): JsonResponse
{
$invalidCodeResponse = $this->validateAuthorizationCode($request);
if ($invalidCodeResponse instanceof JsonResponse) {
return $invalidCodeResponse;
}
return response()->json([
'me' => config('app.url'),
]);
}
/**
* Process a POST request to the IndieAuth token endpoint.
*
* This is another possible second step in the IndieAuth flow, where the client app sends the auth code to the
* IndieAuth token endpoint. As it is to the token endpoint we return an access token.
*
* @throws SodiumException
*/
public function processTokenRequest(Request $request): JsonResponse
{
$indieAuthData = $this->validateAuthorizationCode($request);
if ($indieAuthData instanceof JsonResponse) {
return $indieAuthData;
}
if ($indieAuthData['scope'] === '') {
return response()->json(['errors' => [
'scope' => [
'The scope property must be non-empty for an access token to be issued.',
],
]], 400);
}
$tokenData = [
'me' => config('app.url'),
'client_id' => $request->get('client_id'),
'scope' => $indieAuthData['scope'],
];
$tokenService = resolve(TokenService::class);
$token = $tokenService->getNewToken($tokenData);
return response()->json([
'access_token' => $token,
'token_type' => 'Bearer',
'scope' => $indieAuthData['scope'],
'me' => config('app.url'),
]);
}
protected function isValidRedirectUri(string $clientId, string $redirectUri): bool
{
// If client_id is not a valid URL, then it's not valid
$clientIdParsed = \Mf2\parseUriToComponents($clientId);
if (! isset($clientIdParsed['authority'])) {
return false;
}
// If redirect_uri is not a valid URL, then it's not valid
$redirectUriParsed = \Mf2\parseUriToComponents($redirectUri);
if (! isset($redirectUriParsed['authority'])) {
return false;
}
// If client_id and redirect_uri are the same host, then it's valid
if ($clientIdParsed['authority'] === $redirectUriParsed['authority']) {
return true;
}
// Otherwise we need to check the redirect_uri is in the client_id's redirect_uris
$guzzle = resolve(Client::class);
try {
$clientInfo = $guzzle->get($clientId);
} catch (Exception) {
return false;
}
$clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId);
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];
return in_array($redirectUri, $redirectUris, true);
}
/**
* @throws SodiumException
*/
protected function validateAuthorizationCode(Request $request): JsonResponse|array
{
// First check all the data is present
$validator = Validator::make($request->all(), [
'grant_type' => 'required:string',
'code' => 'required:string',
'client_id' => 'required',
'redirect_uri' => 'required',
'code_verifier' => 'required',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 400);
}
if ($request->get('grant_type') !== 'authorization_code') {
return response()->json(['errors' => [
'grant_type' => [
'Only a grant type of "authorization_code" is supported.',
],
]], 400);
}
// Check cache for auth code
$cacheKey = hash('xxh3', $request->get('client_id'));
$indieAuthRequestData = Cache::pull($cacheKey);
if ($indieAuthRequestData === null) {
return response()->json(['errors' => [
'code' => [
'The code is invalid.',
],
]], 404);
}
// Check the IndieAuth code
if (! array_key_exists('auth_code', $indieAuthRequestData)) {
return response()->json(['errors' => [
'code' => [
'The code is invalid.',
],
]], 400);
}
if ($indieAuthRequestData['auth_code'] !== $request->get('code')) {
return response()->json(['errors' => [
'code' => [
'The code is invalid.',
],
]], 400);
}
// Check code verifier
if (! array_key_exists('code_challenge', $indieAuthRequestData)) {
return response()->json(['errors' => [
'code_verifier' => [
'The code verifier is invalid.',
],
]], 400);
}
if (! hash_equals(
$indieAuthRequestData['code_challenge'],
sodium_bin2base64(
hash('sha256', $request->get('code_verifier'), true),
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
)
)) {
return response()->json(['errors' => [
'code_verifier' => [
'The code verifier is invalid.',
],
]], 400);
}
// Check redirect_uri
if (! array_key_exists('redirect_uri', $indieAuthRequestData)) {
return response()->json(['errors' => [
'redirect_uri' => [
'The redirect uri is invalid.',
],
]], 400);
}
if ($indieAuthRequestData['redirect_uri'] !== $request->get('redirect_uri')) {
return response()->json(['errors' => [
'redirect_uri' => [
'The redirect uri is invalid.',
],
]], 400);
}
// Check client_id
if (! array_key_exists('client_id', $indieAuthRequestData)) {
return response()->json(['errors' => [
'client_id' => [
'The client id is invalid.',
],
]], 400);
}
if ($indieAuthRequestData['client_id'] !== $request->get('client_id')) {
return response()->json(['errors' => [
'client_id' => [
'The client id is invalid.',
],
]], 400);
}
return $indieAuthRequestData;
}
}

View file

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

View file

@ -7,29 +7,52 @@ 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;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Exception\NotReadableException;
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;
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
{ {
$tokenData = $request->input('token_data'); try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses();
$scopes = $tokenData['scope']; return $micropubResponses->invalidTokenResponse();
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse(); if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses();
return $micropubResponses->tokenHasNoScopeResponse();
}
if (Str::contains($tokenData->claims()->get('scope'), 'create') === false) {
$micropubResponses = new MicropubResponses();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->input('q') === 'last') { if ($request->input('q') === 'last') {
@ -80,14 +103,24 @@ class MicropubMediaController extends Controller
*/ */
public function media(Request $request): JsonResponse public function media(Request $request): JsonResponse
{ {
$tokenData = $request->input('token_data'); try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure $exception) {
$micropubResponses = new MicropubResponses();
$scopes = $tokenData['scope']; return $micropubResponses->invalidTokenResponse();
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse(); if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses();
return $micropubResponses->tokenHasNoScopeResponse();
}
if (Str::contains($tokenData->claims()->get('scope'), 'create') === false) {
$micropubResponses = new MicropubResponses();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->hasFile('file') === false) { if ($request->hasFile('file') === false) {
@ -98,10 +131,7 @@ class MicropubMediaController extends Controller
], 400); ], 400);
} }
/** @var UploadedFile $file */ if ($request->file('file')->isValid() === false) {
$file = $request->file('file');
if ($file->isValid() === false) {
return response()->json([ return response()->json([
'response' => 'error', 'response' => 'error',
'error' => 'invalid_request', 'error' => 'invalid_request',
@ -109,25 +139,31 @@ class MicropubMediaController extends Controller
], 400); ], 400);
} }
$filename = Storage::disk('local')->putFile('media', $file); $filename = $this->saveFile($request->file('file'));
/** @var ImageManager $manager */
$manager = resolve(ImageManager::class); $manager = resolve(ImageManager::class);
try { try {
$image = $manager->read($request->file('file')); $image = $manager->make($request->file('file'));
$width = $image->width(); $width = $image->width();
} catch (Exception) { } catch (NotReadableException $exception) {
// not an image // not an image
$width = null; $width = null;
} }
$media = Media::create([ $media = Media::create([
'token' => $request->input('access_token'), 'token' => $request->bearerToken(),
'path' => $filename, 'path' => 'media/' . $filename,
'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()), 'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()),
'image_widths' => $width, 'image_widths' => $width,
]); ]);
// put the file on S3 initially, the ProcessMedia job may edit this
Storage::disk('s3')->putFileAs(
'media',
new File(storage_path('app') . '/' . $filename),
$filename
);
ProcessMedia::dispatch($filename); ProcessMedia::dispatch($filename);
return response()->json([ return response()->json([
@ -149,7 +185,7 @@ class MicropubMediaController extends Controller
*/ */
private function getFileTypeFromMimeType(string $mimeType): string private function getFileTypeFromMimeType(string $mimeType): string
{ {
// try known images //try known images
$imageMimeTypes = [ $imageMimeTypes = [
'image/gif', 'image/gif',
'image/jpeg', 'image/jpeg',
@ -161,7 +197,7 @@ class MicropubMediaController extends Controller
if (in_array($mimeType, $imageMimeTypes)) { if (in_array($mimeType, $imageMimeTypes)) {
return 'image'; return 'image';
} }
// try known video //try known video
$videoMimeTypes = [ $videoMimeTypes = [
'video/mp4', 'video/mp4',
'video/mpeg', 'video/mpeg',
@ -172,7 +208,7 @@ class MicropubMediaController extends Controller
if (in_array($mimeType, $videoMimeTypes)) { if (in_array($mimeType, $videoMimeTypes)) {
return 'video'; return 'video';
} }
// try known audio types //try known audio types
$audioMimeTypes = [ $audioMimeTypes = [
'audio/midi', 'audio/midi',
'audio/mpeg', 'audio/mpeg',
@ -191,7 +227,7 @@ class MicropubMediaController extends Controller
* *
* @throws Exception * @throws Exception
*/ */
private function saveFileToLocal(UploadedFile $file): string private function saveFile(UploadedFile $file): string
{ {
$filename = Uuid::uuid4()->toString() . '.' . $file->extension(); $filename = Uuid::uuid4()->toString() . '.' . $file->extension();
Storage::disk('local')->putFileAs('', $file, $filename); Storage::disk('local')->putFileAs('', $file, $filename);

View file

@ -5,33 +5,32 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Note; use App\Models\Note;
use App\Services\ActivityStreamsService;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\View\View; use Illuminate\View\View;
use Jonnybarnes\IndieWeb\Numbers; use Jonnybarnes\IndieWeb\Numbers;
/** // Need to sort out Twitter and webmentions!
* @todo Need to sort out Twitter and webmentions!
*/
class NotesController extends Controller class NotesController extends Controller
{ {
/** /**
* Show all the notes. This is also the homepage. * Show all the notes. This is also the homepage.
*/ */
public function index(): View|Response public function index(Request $request): View|Response
{ {
if ($request->wantsActivityStream()) {
return (new ActivityStreamsService())->siteOwnerResponse();
}
$notes = Note::latest() $notes = Note::latest()
->with('place', 'media', 'client') ->with('place', 'media', 'client')
->withCount(['webmentions AS replies' => function ($query) { ->withCount(['webmentions As replies' => function ($query) {
$query->where('type', 'in-reply-to'); $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); }])->paginate(10);
return view('notes.index', compact('notes')); return view('notes.index', compact('notes'));
@ -43,20 +42,15 @@ class NotesController extends Controller
public function show(string $urlId): View|JsonResponse|Response public function show(string $urlId): View|JsonResponse|Response
{ {
try { try {
$note = Note::nb60($urlId)->with('place', 'media', 'client') $note = Note::nb60($urlId)->with('webmentions')->firstOrFail();
->withCount(['webmentions AS replies' => function ($query) {
$query->where('type', 'in-reply-to');
}])
->withCount(['webmentions AS likes' => function ($query) {
$query->where('type', 'like-of');
}])
->withCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}])->firstOrFail();
} catch (ModelNotFoundException $exception) { } catch (ModelNotFoundException $exception) {
abort(404); abort(404);
} }
if (request()->wantsActivityStream()) {
return (new ActivityStreamsService())->singleNoteResponse($note);
}
return view('notes.show', compact('note')); return view('notes.show', compact('note'));
} }
@ -65,7 +59,7 @@ class NotesController extends Controller
*/ */
public function redirect(int $decId): RedirectResponse public function redirect(int $decId): RedirectResponse
{ {
return redirect(config('app.url') . '/notes/' . (new Numbers)->numto60($decId)); return redirect(config('app.url') . '/notes/' . (new Numbers())->numto60($decId));
} }
/** /**

View file

@ -17,16 +17,7 @@ class SearchController extends Controller
/** @var Note $note */ /** @var Note $note */
foreach ($notes as $note) { foreach ($notes as $note) {
$note->load('place', 'media', 'client') $note->load('place', 'media', 'client');
->loadCount(['webmentions AS replies' => function ($query) {
$query->where('type', 'in-reply-to');
}])
->loadCount(['webmentions AS likes' => function ($query) {
$query->where('type', 'like-of');
}])
->loadCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}]);
} }
return view('search', compact('search', 'notes')); return view('search', compact('search', 'notes'));

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
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

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\TokenService;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Exception\BadResponseException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use IndieAuth\Client;
use JsonException;
class TokenEndpointController extends Controller
{
/**
* @var Client The IndieAuth Client.
*/
protected Client $client;
/**
* @var GuzzleClient The GuzzleHttp client.
*/
protected GuzzleClient $guzzle;
protected TokenService $tokenService;
/**
* Inject the dependencies.
*/
public function __construct(
Client $client,
GuzzleClient $guzzle,
TokenService $tokenService
) {
$this->client = $client;
$this->guzzle = $guzzle;
$this->tokenService = $tokenService;
}
/**
* If the user has authd via the IndieAuth protocol, issue a valid token.
*/
public function create(Request $request): JsonResponse
{
$auth = $this->verifyIndieAuthCode(
config('app.authorization_endpoint'),
$request->input('code'),
$request->input('redirect_uri'),
$request->input('client_id'),
);
if ($auth === null || ! array_key_exists('me', $auth)) {
return response()->json([
'error' => 'There was an error verifying the IndieAuth code',
], 401);
}
$scope = $auth['scope'] ?? '';
$tokenData = [
'me' => config('app.url'),
'client_id' => $request->input('client_id'),
'scope' => $scope,
];
$token = $this->tokenService->getNewToken($tokenData);
$content = [
'me' => config('app.url'),
'scope' => $scope,
'access_token' => $token,
];
return response()->json($content);
}
protected function verifyIndieAuthCode(
string $authorizationEndpoint,
string $code,
string $redirectUri,
string $clientId
): ?array {
try {
$response = $this->guzzle->request('POST', $authorizationEndpoint, [
'headers' => [
'Accept' => 'application/json',
],
'form_params' => [
'code' => $code,
'me' => config('app.url'),
'redirect_uri' => $redirectUri,
'client_id' => $clientId,
],
]);
} catch (BadResponseException) {
return null;
}
try {
$authData = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
return $authData;
}
}

View file

@ -30,7 +30,7 @@ class WebMentionsController extends Controller
*/ */
public function receive(Request $request): Response public function receive(Request $request): Response
{ {
// first we trivially reject requests that lack all required inputs //first we trivially reject requests that lack all required inputs
if (($request->has('target') !== true) || ($request->has('source') !== true)) { if (($request->has('target') !== true) || ($request->has('source') !== true)) {
return response( return response(
'You need both the target and source parameters', 'You need both the target and source parameters',
@ -38,12 +38,12 @@ class WebMentionsController extends Controller
); );
} }
// next check the $target is valid //next check the $target is valid
$path = parse_url($request->input('target'), PHP_URL_PATH); $path = parse_url($request->input('target'), PHP_URL_PATH);
$pathParts = explode('/', $path); $pathParts = explode('/', $path);
if ($pathParts[1] === 'notes') { if ($pathParts[1] === 'notes') {
// we have a note //we have a note
$noteId = $pathParts[2]; $noteId = $pathParts[2];
try { try {
$note = Note::findOrFail(resolve(Numbers::class)->b60tonum($noteId)); $note = Note::findOrFail(resolve(Numbers::class)->b60tonum($noteId));

75
app/Http/Kernel.php Normal file
View file

@ -0,0 +1,75 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\LinkHeadersMiddleware::class,
\App\Http\Middleware\LocalhostSessionMiddleware::class,
\App\Http\Middleware\ActivityStreamLinks::class,
\App\Http\Middleware\CSPHeader::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used to conveniently assign middleware to routes and groups.
*
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'micropub.token' => \App\Http\Middleware\VerifyMicropubToken::class,
'myauth' => \App\Http\Middleware\MyAuthMiddleware::class,
'cors' => \App\Http\Middleware\CorsHeaders::class,
];
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ActivityStreamLinks
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($request->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;
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class CSPHeader
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (App::environment('local', 'development')) {
return $next($request);
}
// headers have to be single-line strings,
// so we concat multiple lines
// phpcs:disable Generic.Files.LineLength.TooLong
return $next($request)
->header(
'Content-Security-Policy',
"default-src 'self'; " .
"style-src 'self' cloud.typography.com jonnybarnes.uk; " .
"img-src 'self' data: blob: https://pbs.twimg.com https://jbuk-media.s3-eu-west-1.amazonaws.com https://jbuk-media-dev.s3-eu-west-1.amazonaws.com https://secure.gravatar.com https://graph.facebook.com *.fbcdn.net https://*.cdninstagram.com https://*.4sqi.net https://upload.wikimedia.org; " .
"font-src 'self' data:; " .
"frame-src 'self' https://www.youtube.com blob:; " .
'upgrade-insecure-requests; ' .
'block-all-mixed-content; ' .
'report-to csp-endpoint; ' .
'report-uri https://jonnybarnes.report-uri.io/r/default/csp/enforce;'
)->header(
'Report-To',
'{' .
"'url': 'https://jonnybarnes.report-uri.io/r/default/csp/enforce', " .
"'group': 'csp-endpoint', " .
"'max-age': 10886400" .
'}'
);
// phpcs:enable Generic.Files.LineLength.TooLong
}
}

View file

@ -14,11 +14,10 @@ class LinkHeadersMiddleware
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$response = $next($request); $response = $next($request);
$response->header('Link', '<' . route('indieauth.metadata') . '>; rel="indieauth-metadata"', false); $response->header('Link', '<https://indieauth.com/auth>; rel="authorization_endpoint"', false);
$response->header('Link', '<' . route('indieauth.start') . '>; rel="authorization_endpoint"', false); $response->header('Link', '<' . config('app.url') . '/api/token>; rel="token_endpoint"', false);
$response->header('Link', '<' . route('indieauth.token') . '>; rel="token_endpoint"', false); $response->header('Link', '<' . config('app.url') . '/api/post>; rel="micropub"', false);
$response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false); $response->header('Link', '<' . config('app.url') . '/webmention>; rel="webmention"', false);
$response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false);
return $response; return $response;
} }

View file

@ -1,24 +0,0 @@
<?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

@ -18,8 +18,6 @@ class MyAuthMiddleware
{ {
if (Auth::check() === false) { if (Auth::check() === false) {
// theyre not logged in, so send them to login form // theyre not logged in, so send them to login form
redirect()->setIntendedUrl($request->fullUrl());
return redirect()->route('login'); return redirect()->route('login');
} }

View file

@ -1,22 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
}

View file

@ -4,78 +4,31 @@ 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
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
*
* @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')) {
$rawToken = $request->input('access_token'); return $next($request);
} 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

@ -1,106 +0,0 @@
<?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

@ -24,7 +24,8 @@ class DownloadWebMention implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected string $source protected string $source
) {} ) {
}
/** /**
* Execute the job. * Execute the job.
@ -35,30 +36,30 @@ class DownloadWebMention implements ShouldQueue
public function handle(Client $guzzle): void public function handle(Client $guzzle): void
{ {
$response = $guzzle->request('GET', $this->source); $response = $guzzle->request('GET', $this->source);
// 4XX and 5XX responses should get Guzzle to throw an exception, //4XX and 5XX responses should get Guzzle to throw an exception,
// Laravel should catch and retry these automatically. //Laravel should catch and retry these automatically.
if ($response->getStatusCode() === 200) { if ($response->getStatusCode() === 200) {
$filesystem = new FileSystem; $filesystem = new FileSystem();
$filename = storage_path('HTML') . '/' . $this->createFilenameFromURL($this->source); $filename = storage_path('HTML') . '/' . $this->createFilenameFromURL($this->source);
// backup file first //backup file first
$filenameBackup = $filename . '.' . date('Y-m-d') . '.backup'; $filenameBackup = $filename . '.' . date('Y-m-d') . '.backup';
if ($filesystem->exists($filename)) { if ($filesystem->exists($filename)) {
$filesystem->copy($filename, $filenameBackup); $filesystem->copy($filename, $filenameBackup);
} }
// check if base directory exists //check if base directory exists
if (! $filesystem->exists($filesystem->dirname($filename))) { if (! $filesystem->exists($filesystem->dirname($filename))) {
$filesystem->makeDirectory( $filesystem->makeDirectory(
$filesystem->dirname($filename), $filesystem->dirname($filename),
0755, // mode 0755, //mode
true // recursive true //recursive
); );
} }
// save new HTML //save new HTML
$filesystem->put( $filesystem->put(
$filename, $filename,
(string) $response->getBody() (string) $response->getBody()
); );
// remove backup if the same //remove backup if the same
if ($filesystem->exists($filenameBackup)) { if ($filesystem->exists($filenameBackup)) {
if ($filesystem->get($filename) === $filesystem->get($filenameBackup)) { if ($filesystem->get($filename) === $filesystem->get($filenameBackup)) {
$filesystem->delete($filenameBackup); $filesystem->delete($filenameBackup);

View file

@ -25,7 +25,8 @@ class ProcessBookmark implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected Bookmark $bookmark protected Bookmark $bookmark
) {} ) {
}
/** /**
* Execute the job. * Execute the job.

View file

@ -30,7 +30,8 @@ class ProcessLike implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected Like $like protected Like $like
) {} ) {
}
/** /**
* Execute the job. * Execute the job.
@ -49,7 +50,7 @@ class ProcessLike implements ShouldQueue
$this->like->content = $tweet->html; $this->like->content = $tweet->html;
$this->like->save(); $this->like->save();
// POSSE like //POSSE like
try { try {
$client->request( $client->request(
'POST', 'POST',

View file

@ -10,7 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Exceptions\DecoderException; use Intervention\Image\Exception\NotReadableException;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
class ProcessMedia implements ShouldQueue class ProcessMedia implements ShouldQueue
@ -25,45 +25,43 @@ class ProcessMedia implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected string $filename protected string $filename
) {} ) {
}
/** /**
* Execute the job. * Execute the job.
*/ */
public function handle(ImageManager $manager): void public function handle(ImageManager $manager): void
{ {
// Load file //open file
$file = Storage::disk('local')->get('media/' . $this->filename);
// Open file
try { try {
$image = $manager->read($file); $image = $manager->make(storage_path('app') . '/' . $this->filename);
} catch (DecoderException) { } catch (NotReadableException $exception) {
// not an image; delete file and end job // not an image; delete file and end job
Storage::disk('local')->delete('media/' . $this->filename); unlink(storage_path('app') . '/' . $this->filename);
return; return;
} }
//create smaller versions if necessary
// Save the file publicly
Storage::disk('public')->put('media/' . $this->filename, $file);
// Create smaller versions if necessary
if ($image->width() > 1000) { if ($image->width() > 1000) {
$filenameParts = explode('.', $this->filename); $filenameParts = explode('.', $this->filename);
$extension = array_pop($filenameParts); $extension = array_pop($filenameParts);
// the following achieves this data flow // the following achieves this data flow
// foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
$basename = trim(implode('.', $filenameParts), '.'); $basename = ltrim(array_reduce($filenameParts, function ($carry, $item) {
return $carry . '.' . $item;
$medium = $image->resize(width: 1000); }, ''), '.');
Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode()); $medium = $image->resize(1000, null, function ($constraint) {
$constraint->aspectRatio();
$small = $image->resize(width: 500); });
Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); Storage::disk('s3')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode());
$small = $image->resize(500, null, function ($constraint) {
$constraint->aspectRatio();
});
Storage::disk('s3')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode());
} }
// Now we can delete the locally saved image // now we can delete the locally saved image
Storage::disk('local')->delete('media/' . $this->filename); unlink(storage_path('app') . '/' . $this->filename);
} }
} }

View file

@ -30,7 +30,8 @@ class ProcessWebMention implements ShouldQueue
public function __construct( public function __construct(
protected Note $note, protected Note $note,
protected string $source protected string $source
) {} ) {
}
/** /**
* Execute the job. * Execute the job.
@ -44,7 +45,7 @@ class ProcessWebMention implements ShouldQueue
try { try {
$response = $guzzle->request('GET', $this->source); $response = $guzzle->request('GET', $this->source);
} catch (RequestException $e) { } catch (RequestException $e) {
throw new RemoteContentNotFoundException; throw new RemoteContentNotFoundException();
} }
$this->saveRemoteContent((string) $response->getBody(), $this->source); $this->saveRemoteContent((string) $response->getBody(), $this->source);
$microformats = Mf2\parse((string) $response->getBody(), $this->source); $microformats = Mf2\parse((string) $response->getBody(), $this->source);
@ -53,7 +54,7 @@ class ProcessWebMention implements ShouldQueue
// check webmention still references target // 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->uri) === false) { if ($parser->checkInReplyTo($microformats, $this->note->longurl) === false) {
// it doesnt so delete // it doesnt so delete
$webmention->delete(); $webmention->delete();
@ -67,7 +68,7 @@ class ProcessWebMention implements ShouldQueue
return; return;
} }
if ($webmention->type === 'like-of') { if ($webmention->type === 'like-of') {
if ($parser->checkLikeOf($microformats, $this->note->uri) === false) { if ($parser->checkLikeOf($microformats, $this->note->longurl) === false) {
// it doesnt so delete // it doesnt so delete
$webmention->delete(); $webmention->delete();
@ -75,7 +76,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->uri) === false) { if ($parser->checkRepostOf($microformats, $this->note->longurl) === false) {
// it doesnt so delete // it doesnt so delete
$webmention->delete(); $webmention->delete();
@ -85,13 +86,13 @@ class ProcessWebMention implements ShouldQueue
}// foreach }// foreach
// no webmention in the db so create new one // no webmention in the db so create new one
$webmention = new WebMention; $webmention = new WebMention();
$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->uri; $webmention->target = $this->note->longurl;
$webmention->commentable_id = $this->note->id; $webmention->commentable_id = $this->note->id;
$webmention->commentable_type = Note::class; $webmention->commentable_type = 'App\Model\Note';
$webmention->type = $type; $webmention->type = $type;
$webmention->mf2 = json_encode($microformats); $webmention->mf2 = json_encode($microformats);
$webmention->save(); $webmention->save();

View file

@ -25,7 +25,8 @@ class SaveProfileImage implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected array $microformats protected array $microformats
) {} ) {
}
/** /**
* Execute the job. * Execute the job.
@ -41,15 +42,7 @@ class SaveProfileImage implements ShouldQueue
$photo = Arr::get($author, 'properties.photo.0'); $photo = Arr::get($author, 'properties.photo.0');
$home = Arr::get($author, 'properties.url.0'); $home = Arr::get($author, 'properties.url.0');
if (is_array($photo) && array_key_exists('value', $photo)) { //dont save pbs.twimg.com links
$photo = $photo['value'];
}
if (is_array($home)) {
$home = array_shift($home);
}
// dont save pbs.twimg.com links
if ( if (
$photo $photo
&& parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com' && parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com'

View file

@ -23,7 +23,8 @@ class SaveScreenshot implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected Bookmark $bookmark protected Bookmark $bookmark
) {} ) {
}
/** /**
* Execute the job. * Execute the job.

View file

@ -27,7 +27,8 @@ class SendWebMentions implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected Note $note protected Note $note
) {} ) {
}
/** /**
* Execute the job. * Execute the job.
@ -45,7 +46,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->uri, 'source' => $this->note->longurl,
'target' => $url, 'target' => $url,
], ],
]); ]);
@ -61,7 +62,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) === parse_url(config('app.url'), PHP_URL_HOST)) { if (parse_url($url, PHP_URL_HOST) === config('app.longurl')) {
return null; return null;
} }
if (Str::startsWith($url, '/notes/tagged/')) { if (Str::startsWith($url, '/notes/tagged/')) {
@ -70,17 +71,18 @@ class SendWebMentions implements ShouldQueue
$endpoint = null; $endpoint = null;
/** @var Client $guzzle */
$guzzle = resolve(Client::class); $guzzle = resolve(Client::class);
$response = $guzzle->get($url); $response = $guzzle->get($url);
// check HTTP Headers for webmention endpoint //check HTTP Headers for webmention endpoint
$links = Header::parse($response->getHeader('Link')); $links = Header::parse($response->getHeader('Link'));
foreach ($links as $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); return $this->resolveUri(trim($link[0], '<>'), $url);
} }
} }
// failed to find a header so parse HTML //failed to find a header so parse HTML
$html = (string) $response->getBody(); $html = (string) $response->getBody();
$mf2 = new \Mf2\Parser($html, $url); $mf2 = new \Mf2\Parser($html, $url);
@ -108,7 +110,7 @@ class SendWebMentions implements ShouldQueue
} }
$urls = []; $urls = [];
$dom = new \DOMDocument; $dom = new \DOMDocument();
$dom->loadHTML($html); $dom->loadHTML($html);
$anchors = $dom->getElementsByTagName('a'); $anchors = $dom->getElementsByTagName('a');
foreach ($anchors as $anchor) { foreach ($anchors as $anchor) {

View file

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyndicateNoteToBluesky implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
protected Note $note
) {}
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Client $guzzle): void
{
// We can only make the request if we have an access token
if (config('bridgy.bluesky_token') === null) {
return;
}
// Make micropub request
$response = $guzzle->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();
}
}
}

View file

@ -22,7 +22,8 @@ class SyndicateNoteToMastodon implements ShouldQueue
*/ */
public function __construct( public function __construct(
protected Note $note protected Note $note
) {} ) {
}
/** /**
* Execute the job. * Execute the job.

View file

@ -58,10 +58,10 @@ class Article extends Model
{ {
return Attribute::get( return Attribute::get(
get: function () { get: function () {
$environment = new Environment; $environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension); $environment->addExtension(new CommonMarkCoreExtension());
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer); $environment->addRenderer(FencedCode::class, new FencedCodeRenderer());
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer); $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer());
$markdownConverter = new MarkdownConverter($environment); $markdownConverter = new MarkdownConverter($environment);
return $markdownConverter->convert($this->main)->getContent(); return $markdownConverter->convert($this->main)->getContent();
@ -107,7 +107,7 @@ class Article extends Model
/** /**
* Scope a query to only include articles from a particular year/month. * Scope a query to only include articles from a particular year/month.
*/ */
public function scopeDate(Builder $query, ?int $year = null, ?int $month = null): Builder public function scopeDate(Builder $query, int $year = null, int $month = null): Builder
{ {
if ($year === null) { if ($year === null) {
return $query; return $query;

View file

@ -26,7 +26,7 @@ class Bookmark extends Model
return $this->belongsToMany('App\Models\Tag'); return $this->belongsToMany('App\Models\Tag');
} }
protected function local_uri(): Attribute protected function longurl(): 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

@ -33,7 +33,7 @@ class Media extends Model
return $attributes['path']; return $attributes['path'];
} }
return config('app.url') . '/storage/' . $attributes['path']; return config('filesystems.disks.s3.url') . '/' . $attributes['path'];
} }
); );
} }
@ -78,7 +78,7 @@ class Media extends Model
$basename = $this->getBasename($path); $basename = $this->getBasename($path);
$extension = $this->getExtension($path); $extension = $this->getExtension($path);
return config('app.url') . '/storage/' . $basename . '-' . $size . '.' . $extension; return config('filesystems.disks.s3.url') . '/' . $basename . '-' . $size . '.' . $extension;
} }
private function getBasename(string $path): string private function getBasename(string $path): string

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\CommonMark\Generators\MentionGenerator; use App\CommonMark\Generators\ContactMentionGenerator;
use App\CommonMark\Renderers\MentionRenderer; use App\CommonMark\Renderers\ContactMentionRenderer;
use Codebird\Codebird; use Codebird\Codebird;
use Exception; use Exception;
use GuzzleHttp\Client; use GuzzleHttp\Client;
@ -111,7 +111,7 @@ class Note extends Model
{ {
if ($value !== null) { if ($value !== null) {
$normalized = normalizer_normalize($value, Normalizer::FORM_C); $normalized = normalizer_normalize($value, Normalizer::FORM_C);
if ($normalized === '') { // we dont want to save empty strings to the db if ($normalized === '') { //we dont want to save empty strings to the db
$normalized = null; $normalized = null;
} }
$this->attributes['note'] = $normalized; $this->attributes['note'] = $normalized;
@ -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->uri . '">' . $this->place->name . '</a>'; $value = '📍: <a href="' . $this->place->longurl . '">' . $this->place->name . '</a>';
} }
// if $value is still null, just return null // if $value is still null, just return null
@ -144,17 +144,17 @@ class Note extends Model
*/ */
public function getContentAttribute(): string public function getContentAttribute(): string
{ {
$note = $this->getRawOriginal('note'); $note = $this->note;
foreach ($this->media as $media) { foreach ($this->media as $media) {
if ($media->type === 'image') { if ($media->type === 'image') {
$note .= PHP_EOL . '<img src="' . $media->url . '" alt="">'; $note .= '<img src="' . $media->url . '" alt="">';
} }
if ($media->type === 'audio') { if ($media->type === 'audio') {
$note .= PHP_EOL . '<audio src="' . $media->url . '">'; $note .= '<audio src="' . $media->url . '">';
} }
if ($media->type === 'video') { if ($media->type === 'video') {
$note .= PHP_EOL . '<video src="' . $media->url . '">'; $note .= '<video src="' . $media->url . '">';
} }
} }
@ -172,11 +172,16 @@ class Note extends Model
return (string) resolve(Numbers::class)->numto60($this->id); return (string) resolve(Numbers::class)->numto60($this->id);
} }
public function getUriAttribute(): string public function getLongurlAttribute(): string
{ {
return config('app.url') . '/notes/' . $this->nb60id; return config('app.url') . '/notes/' . $this->nb60id;
} }
public function getShorturlAttribute(): string
{
return config('app.shorturl') . '/notes/' . $this->nb60id;
}
public function getIso8601Attribute(): string public function getIso8601Attribute(): string
{ {
return $this->updated_at->toISO8601String(); return $this->updated_at->toISO8601String();
@ -266,7 +271,7 @@ class Note extends Model
]); ]);
if ($oEmbed->httpstatus >= 400) { if ($oEmbed->httpstatus >= 400) {
throw new Exception; throw new Exception();
} }
} catch (Exception $e) { } catch (Exception $e) {
return null; return null;
@ -281,13 +286,6 @@ class Note extends Model
*/ */
public function scopeNb60(Builder $query, string $nb60id): Builder public function scopeNb60(Builder $query, string $nb60id): Builder
{ {
$realId = resolve(Numbers::class)->b60tonum($nb60id);
// Check nb60 does not translate to ID too big for database int4 column
if ($realId > 2_147_483_647) {
abort(404);
}
return $query->where('id', resolve(Numbers::class)->b60tonum($nb60id)); return $query->where('id', resolve(Numbers::class)->b60tonum($nb60id));
} }
@ -380,21 +378,21 @@ class Note extends Model
{ {
$config = [ $config = [
'mentions' => [ 'mentions' => [
'mentions_handle' => [ 'contacts_handle' => [
'prefix' => '@', 'prefix' => '@',
'pattern' => '([\w@.])+(\b)', 'pattern' => '[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)',
'generator' => new MentionGenerator, 'generator' => new ContactMentionGenerator(),
], ],
], ],
]; ];
$environment = new Environment($config); $environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension); $environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension); $environment->addExtension(new AutolinkExtension());
$environment->addExtension(new MentionExtension); $environment->addExtension(new MentionExtension());
$environment->addRenderer(Mention::class, new MentionRenderer); $environment->addRenderer(Mention::class, new ContactMentionRenderer());
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer); $environment->addRenderer(FencedCode::class, new FencedCodeRenderer());
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer); $environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer());
$markdownConverter = new MarkdownConverter($environment); $markdownConverter = new MarkdownConverter($environment);
return $markdownConverter->convert($note)->getContent(); return $markdownConverter->convert($note)->getContent();

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Passkey extends Model
{
use HasFactory;
/** @inerhitDoc */
protected $fillable = [
'passkey_id',
'passkey',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -59,7 +59,7 @@ class Place extends Model
* sin(radians(places.latitude))))"; * sin(radians(places.latitude))))";
return $query return $query
->select() // pick the columns you want here. ->select() //pick the columns you want here.
->selectRaw("{$haversine} AS distance") ->selectRaw("{$haversine} AS distance")
->whereRaw("{$haversine} < ?", [$distance]); ->whereRaw("{$haversine} < ?", [$distance]);
} }
@ -74,10 +74,24 @@ 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('app.shorturl') . '/places/' . $attributes['slug'],
);
}
protected function uri(): Attribute protected function uri(): Attribute
{ {
return Attribute::get( return Attribute::get(
get: static fn ($value, $attributes) => config('app.url') . '/places/' . $attributes['slug'], get: fn () => $this->longurl,
); );
} }

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -25,9 +24,4 @@ class User extends Authenticatable
'password', 'password',
'remember_token', 'remember_token',
]; ];
public function passkey(): HasMany
{
return $this->hasMany(Passkey::class);
}
} }

View file

@ -42,7 +42,7 @@ class WebMention extends Model
return null; return null;
} }
$authorship = new Authorship; $authorship = new Authorship();
$hCard = $authorship->findAuthor(json_decode($attributes['mf2'], true)); $hCard = $authorship->findAuthor(json_decode($attributes['mf2'], true));
if ($hCard === false) { if ($hCard === false) {
@ -109,21 +109,13 @@ class WebMention extends Model
/** /**
* Create the photo link. * Create the photo link.
*/ */
public function createPhotoLink(string|array $url): string public function createPhotoLink(string $url): string
{ {
if (is_array($url)) {
if (! array_key_exists('value', $url)) {
return '';
}
$url = $url['value'];
}
$url = normalize_url($url); $url = normalize_url($url);
$host = parse_url($url, PHP_URL_HOST); $host = parse_url($url, PHP_URL_HOST);
if ($host === 'pbs.twimg.com') { if ($host === 'pbs.twimg.com') {
// make sure we use HTTPS, we know twitter supports it //make sure we use HTTPS, we know twitter supports it
return str_replace('http://', 'https://', $url); return str_replace('http://', 'https://', $url);
} }
@ -135,12 +127,12 @@ class WebMention extends Model
$codebird = resolve(Codebird::class); $codebird = resolve(Codebird::class);
$info = $codebird->users_show(['screen_name' => $username]); $info = $codebird->users_show(['screen_name' => $username]);
$profile_image = $info->profile_image_url_https; $profile_image = $info->profile_image_url_https;
Cache::put($url, $profile_image, 10080); // 1 week Cache::put($url, $profile_image, 10080); //1 week
return $profile_image; return $profile_image;
} }
$filesystem = new Filesystem; $filesystem = new Filesystem();
if ($filesystem->exists(public_path() . '/assets/profile-images/' . $host . '/image')) { if ($filesystem->exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
return '/assets/profile-images/' . $host . '/image'; return '/assets/profile-images/' . $host . '/image';
} }

View file

@ -12,7 +12,7 @@ use Illuminate\Support\Collection;
class NoteObserver class NoteObserver
{ {
/** /**
* Listen to the Note created event.= * Listen to the Note created event.
*/ */
public function created(Note $note): void public function created(Note $note): void
{ {
@ -34,7 +34,7 @@ class NoteObserver
} }
/** /**
* Listen to the Note updated event.= * Listen to the Note updated event.
*/ */
public function updated(Note $note): void public function updated(Note $note): void
{ {
@ -58,7 +58,7 @@ class NoteObserver
} }
/** /**
* Listen to the Note deleting event.= * Listen to the Note deleting event.
*/ */
public function deleting(Note $note): void public function deleting(Note $note): void
{ {

View file

@ -8,9 +8,12 @@ use Codebird\Codebird;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Middleware; use GuzzleHttp\Middleware;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Dusk\DuskServiceProvider;
use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Key\InMemory;
@ -20,14 +23,6 @@ use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
/**
* Register any application services.
*/
public function register(): void
{
//
}
/** /**
* Bootstrap any application services. * Bootstrap any application services.
*/ */
@ -35,9 +30,14 @@ class AppServiceProvider extends ServiceProvider
{ {
Note::observe(NoteObserver::class); Note::observe(NoteObserver::class);
// Request AS macro
Request::macro('wantsActivityStream', function () {
return Str::contains(mb_strtolower($this->header('Accept')), 'application/activity+json');
});
// configure Intervention/Image // configure Intervention/Image
$this->app->bind('Intervention\Image\ImageManager', function () { $this->app->bind('Intervention\Image\ImageManager', function () {
return \Intervention\Image\ImageManager::withDriver(config('image.driver')); return new \Intervention\Image\ImageManager(['driver' => config('image.driver')]);
}); });
// Bind the Codebird client // Bind the Codebird client
@ -88,9 +88,9 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind('Lcobucci\JWT\Configuration', function () { $this->app->bind('Lcobucci\JWT\Configuration', function () {
$key = InMemory::plainText(config('app.key')); $key = InMemory::plainText(config('app.key'));
$config = Configuration::forSymmetricSigner(new Sha256, $key); $config = Configuration::forSymmetricSigner(new Sha256(), $key);
$config->setValidationConstraints(new SignedWith(new Sha256, $key)); $config->setValidationConstraints(new SignedWith(new Sha256(), $key));
return $config; return $config;
}); });
@ -98,7 +98,7 @@ class AppServiceProvider extends ServiceProvider
// Configure HtmlSanitizer // Configure HtmlSanitizer
$this->app->bind(HtmlSanitizer::class, function () { $this->app->bind(HtmlSanitizer::class, function () {
return new HtmlSanitizer( return new HtmlSanitizer(
(new HtmlSanitizerConfig) (new HtmlSanitizerConfig())
->allowSafeElements() ->allowSafeElements()
->forceAttribute('a', 'rel', 'noopener nofollow') ->forceAttribute('a', 'rel', 'noopener nofollow')
); );
@ -139,4 +139,14 @@ class AppServiceProvider extends ServiceProvider
// Turn on Eloquent strict mode when developing // Turn on Eloquent strict mode when developing
Model::shouldBeStrict(! $this->app->isProduction()); Model::shouldBeStrict(! $this->app->isProduction());
} }
/**
* Register any application services.
*/
public function register(): void
{
if ($this->app->environment('local', 'testing')) {
$this->app->register(DuskServiceProvider::class);
}
}
} }

View file

@ -0,0 +1,25 @@
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* The model to policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
//
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
/**
* @codeCoverageIgnore
*/
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event to listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
/**
* Register any events for your application.
*/
public function boot(): void
{
//
}
}

View file

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider; use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider class HorizonServiceProvider extends HorizonApplicationServiceProvider
@ -13,6 +14,12 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
public function boot(): void public function boot(): void
{ {
parent::boot(); parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
Horizon::night();
} }
/** /**

View file

@ -1,26 +0,0 @@
<?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

@ -0,0 +1,48 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to the "home" route for your application.
*
* Typically, users are redirected here after authentication.
*
* @var string
*/
public const HOME = '/admin';
/**
* Define your route model bindings, pattern filters, and other route configuration.
*/
public function boot(): void
{
$this->configureRateLimiting();
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
/**
* Configure the rate limiters for the application.
*/
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Note;
use Illuminate\Http\Response;
class ActivityStreamsService
{
/**
* Return the relevant data to an AS2.0 request to the root path.
*/
public function siteOwnerResponse(): Response
{
$data = json_encode([
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => config('app.url'),
'type' => 'Person',
'name' => config('user.displayname'),
'preferredUsername' => config('user.username'),
]);
return response($data)->header('Content-Type', 'application/activity+json');
}
/**
* Return the relevant data to an AS2.0 request for a particular note.
*/
public function singleNoteResponse(Note $note): Response
{
$data = json_encode([
'@context' => 'https://www.w3.org/ns/activitystreams',
'summary' => strtok(config('user.displayname'), ' ') . ' added a note to their microblog',
'type' => 'Add',
'published' => $note->updated_at->toW3cString(),
'actor' => [
'type' => 'Person',
'id' => config('app.url'),
'name' => config('app.display_name'),
'url' => config('app.url'),
'image' => [
'type' => 'Link',
'href' => config('app.url') . '/assets/img/profile.jpg',
'mediaType' => '/image/jpeg',
],
],
'object' => [
'id' => $note->longurl,
'type' => 'Note',
'url' => $note->longurl,
'name' => strip_tags($note->note),
],
]);
return response($data)->header('Content-Type', 'application/activity+json');
}
}

View file

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

View file

@ -10,29 +10,28 @@ 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 class BookmarkService extends Service
{ {
/** /**
* Create a new Bookmark. * Create a new Bookmark.
*/ */
public function create(array $data): Bookmark public function create(array $request, ?string $client = null): Bookmark
{ {
if (Arr::get($data, 'properties.bookmark-of.0')) { if (Arr::get($request, 'properties.bookmark-of.0')) {
// micropub request //micropub request
$url = normalize_url(Arr::get($data, 'properties.bookmark-of.0')); $url = normalize_url(Arr::get($request, 'properties.bookmark-of.0'));
$name = Arr::get($data, 'properties.name.0'); $name = Arr::get($request, 'properties.name.0');
$content = Arr::get($data, 'properties.content.0'); $content = Arr::get($request, 'properties.content.0');
$categories = Arr::get($data, 'properties.category'); $categories = Arr::get($request, 'properties.category');
} }
if (Arr::get($data, 'bookmark-of')) { if (Arr::get($request, 'bookmark-of')) {
$url = normalize_url(Arr::get($data, 'bookmark-of')); $url = normalize_url(Arr::get($request, 'bookmark-of'));
$name = Arr::get($data, 'name'); $name = Arr::get($request, 'name');
$content = Arr::get($data, 'content'); $content = Arr::get($request, 'content');
$categories = Arr::get($data, 'category'); $categories = Arr::get($request, 'category');
} }
$bookmark = Bookmark::create([ $bookmark = Bookmark::create([
@ -55,7 +54,6 @@ class BookmarkService
* 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
{ {
@ -63,8 +61,8 @@ class BookmarkService
try { try {
$response = $client->request('GET', 'https://web.archive.org/save/' . $url); $response = $client->request('GET', 'https://web.archive.org/save/' . $url);
} catch (ClientException $e) { } catch (ClientException $e) {
// throw an exception to be caught //throw an exception to be caught
throw new InternetArchiveException; throw new InternetArchiveException();
} }
if ($response->hasHeader('Content-Location')) { if ($response->hasHeader('Content-Location')) {
if (Str::startsWith(Arr::get($response->getHeader('Content-Location'), 0), '/web')) { if (Str::startsWith(Arr::get($response->getHeader('Content-Location'), 0), '/web')) {
@ -72,7 +70,7 @@ class BookmarkService
} }
} }
// throw an exception to be caught //throw an exception to be caught
throw new InternetArchiveException; throw new InternetArchiveException();
} }
} }

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 class LikeService extends Service
{ {
/** /**
* Create a new Like. * Create a new Like.
*/ */
public function create(array $data): Like public function create(array $request, ?string $client = null): Like
{ {
if (Arr::get($data, 'properties.like-of.0')) { if (Arr::get($request, 'properties.like-of.0')) {
// micropub request //micropub request
$url = normalize_url(Arr::get($data, 'properties.like-of.0')); $url = normalize_url(Arr::get($request, 'properties.like-of.0'));
} }
if (Arr::get($data, 'like-of')) { if (Arr::get($request, 'like-of')) {
$url = normalize_url(Arr::get($data, 'like-of')); $url = normalize_url(Arr::get($request, 'like-of'));
} }
$like = Like::create(['url' => $url]); $like = Like::create(['url' => $url]);

View file

@ -1,34 +0,0 @@
<?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

@ -1,41 +0,0 @@
<?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

@ -0,0 +1,32 @@
<?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

@ -0,0 +1,34 @@
<?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

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

View file

@ -1,34 +0,0 @@
<?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,35 +4,23 @@ 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
{ {
/** /**
* @throws InvalidTokenScopeException * Process a micropub request to update an entry.
*/ */
public function handle(array $data) public function process(array $request): JsonResponse
{ {
$scopes = $data['token_data']['scope']; $urlPath = parse_url(Arr::get($request, 'url'), PHP_URL_PATH);
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes, true)) { //is it a note we are updating?
throw new InvalidTokenScopeException;
}
$urlPath = parse_url(Arr::get($data, 'url'), PHP_URL_PATH);
// is it a note we are updating?
if (mb_substr($urlPath, 1, 5) !== 'notes') { if (mb_substr($urlPath, 1, 5) !== 'notes') {
return response()->json([ return response()->json([
'error' => 'invalid', 'error' => 'invalid',
@ -42,16 +30,16 @@ class UpdateHandler implements MicropubHandlerInterface
try { try {
$note = Note::nb60(basename($urlPath))->firstOrFail(); $note = Note::nb60(basename($urlPath))->firstOrFail();
} catch (ModelNotFoundException) { } catch (ModelNotFoundException $exception) {
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',
], 404); ], 404);
} }
// got the note, are we dealing with a “replace” request? //got the note, are we dealing with a “replace” request?
if (Arr::get($data, 'replace')) { if (Arr::get($request, 'replace')) {
foreach (Arr::get($data, 'replace') as $property => $value) { foreach (Arr::get($request, 'replace') as $property => $value) {
if ($property === 'content') { if ($property === 'content') {
$note->note = $value[0]; $note->note = $value[0];
} }
@ -71,14 +59,14 @@ class UpdateHandler implements MicropubHandlerInterface
} }
$note->save(); $note->save();
return [ return response()->json([
'response' => 'updated', 'response' => 'updated',
]; ]);
} }
// how about “add” //how about “add”
if (Arr::get($data, 'add')) { if (Arr::get($request, 'add')) {
foreach (Arr::get($data, 'add') as $property => $value) { foreach (Arr::get($request, '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')) {
@ -95,7 +83,7 @@ class UpdateHandler implements MicropubHandlerInterface
if ($property === 'photo') { if ($property === 'photo') {
foreach ($value as $photoURL) { foreach ($value as $photoURL) {
if (Str::startsWith($photoURL, 'https://')) { if (Str::startsWith($photoURL, 'https://')) {
$media = new Media; $media = new Media();
$media->path = $photoURL; $media->path = $photoURL;
$media->type = 'image'; $media->type = 'image';
$media->save(); $media->save();

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Jobs\SendWebMentions; use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon; use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
@ -14,52 +13,45 @@ use App\Models\SyndicationTarget;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class NoteService class NoteService extends Service
{ {
/** /**
* Create a new note. * Create a new note.
*/ */
public function create(array $data): Note public function create(array $request, ?string $client = null): 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' => $content, 'note' => $this->getDataByKey($request, 'content'),
'in_reply_to' => $data['in-reply-to'], 'in_reply_to' => $this->getDataByKey($request, 'in-reply-to'),
'client_id' => $data['token_data']['client_id'], 'client_id' => $client,
] ]
); );
if ($published = $this->getPublished($data)) { if ($this->getPublished($request)) {
$note->created_at = $note->updated_at = $published; $note->created_at = $note->updated_at = $this->getPublished($request);
} }
$note->location = $this->getLocation($data); $note->location = $this->getLocation($request);
if ($this->getCheckin($data)) { if ($this->getCheckin($request)) {
$note->place()->associate($this->getCheckin($data)); $note->place()->associate($this->getCheckin($request));
$note->swarm_url = $this->getSwarmUrl($data); $note->swarm_url = $this->getSwarmUrl($request);
}
$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));
$this->dispatchSyndicationJobs($note, $data); if (in_array('mastodon', $this->getSyndicationTargets($request), true)) {
dispatch(new SyndicateNoteToMastodon($note));
}
return $note; return $note;
} }
@ -67,10 +59,14 @@ class NoteService
/** /**
* 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 $data): ?string private function getPublished(array $request): ?string
{ {
if ($data['published']) { if (Arr::get($request, 'properties.published.0')) {
return carbon($data['published'])->toDateTimeString(); return carbon(Arr::get($request, 'properties.published.0'))
->toDateTimeString();
}
if (Arr::get($request, 'published')) {
return carbon(Arr::get($request, 'published'))->toDateTimeString();
} }
return null; return null;
@ -79,13 +75,12 @@ class NoteService
/** /**
* 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 $data): ?string private function getLocation(array $request): ?string
{ {
$location = Arr::get($data, 'location'); $location = Arr::get($request, 'properties.location.0') ?? Arr::get($request, '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 +94,9 @@ class NoteService
/** /**
* 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 $data): ?Place private function getCheckin(array $request): ?Place
{ {
$location = Arr::get($data, 'location'); $location = Arr::get($request, '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 +108,12 @@ class NoteService
) )
)->first(); )->first();
} }
if (Arr::get($data, 'checkin')) { if (Arr::get($request, 'checkin')) {
try { try {
$place = resolve(PlaceService::class)->createPlaceFromCheckin( $place = resolve(PlaceService::class)->createPlaceFromCheckin(
Arr::get($data, 'checkin') Arr::get($request, 'checkin')
); );
} catch (\InvalidArgumentException) { } catch (\InvalidArgumentException $e) {
return null; return null;
} }
@ -142,47 +137,34 @@ class NoteService
/** /**
* 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 $data): ?string private function getSwarmUrl(array $request): ?string
{ {
$syndication = Arr::get($data, 'syndication'); if (str_contains(Arr::get($request, 'properties.syndication.0', ''), 'swarmapp')) {
if ($syndication === null) { return Arr::get($request, 'properties.syndication.0');
return null;
}
if (str_contains($syndication, 'swarmapp')) {
return $syndication;
} }
return null; return null;
} }
/** /**
* Dispatch syndication jobs based on the request data. * Get the syndication targets from the request to create a new note.
*/ */
private function dispatchSyndicationJobs(Note $note, array $request): void private function getSyndicationTargets(array $request): array
{ {
// If no syndication targets are specified, return early $syndication = [];
if (empty($request['mp-syndicate-to'])) { $mpSyndicateTo = Arr::get($request, 'mp-syndicate-to') ?? Arr::get($request, 'properties.mp-syndicate-to');
return; $mpSyndicateTo = Arr::wrap($mpSyndicateTo);
foreach ($mpSyndicateTo as $uid) {
$target = SyndicationTarget::where('uid', $uid)->first();
if ($target && $target->service_name === 'Twitter') {
$syndication[] = 'twitter';
}
if ($target && $target->service_name === 'Mastodon') {
$syndication[] = 'mastodon';
}
} }
// Get the configured syndication targets return $syndication;
$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

@ -14,8 +14,8 @@ class PlaceService
*/ */
public function createPlace(array $data): Place public function createPlace(array $data): Place
{ {
// obviously a place needs a lat/lng, but this could be sent in a geo-url //obviously a place needs a lat/lng, but this could be sent in a geo-url
// if no geo array key, we assume the array already has lat/lng values //if no geo array key, we assume the array already has lat/lng values
if (array_key_exists('geo', $data) && $data['geo'] !== null) { if (array_key_exists('geo', $data) && $data['geo'] !== null) {
preg_match_all( preg_match_all(
'/([0-9\.\-]+)/', '/([0-9\.\-]+)/',
@ -25,7 +25,7 @@ class PlaceService
$data['latitude'] = $matches[0][0]; $data['latitude'] = $matches[0][0];
$data['longitude'] = $matches[0][1]; $data['longitude'] = $matches[0][1];
} }
$place = new Place; $place = new Place();
$place->name = $data['name']; $place->name = $data['name'];
$place->description = $data['description']; $place->description = $data['description'];
$place->latitude = $data['latitude']; $place->latitude = $data['latitude'];
@ -40,7 +40,7 @@ class PlaceService
*/ */
public function createPlaceFromCheckin(array $checkin): Place public function createPlaceFromCheckin(array $checkin): Place
{ {
// check if the place exists if from swarm //check if the place exists if from swarm
if (Arr::has($checkin, 'properties.url')) { if (Arr::has($checkin, 'properties.url')) {
$place = Place::whereExternalURL(Arr::get($checkin, 'properties.url.0'))->get(); $place = Place::whereExternalURL(Arr::get($checkin, 'properties.url.0'))->get();
if (count($place) === 1) { if (count($place) === 1) {
@ -53,7 +53,7 @@ class PlaceService
if (Arr::has($checkin, 'properties.latitude') === false) { if (Arr::has($checkin, 'properties.latitude') === false) {
throw new \InvalidArgumentException('Missing required longitude/latitude'); throw new \InvalidArgumentException('Missing required longitude/latitude');
} }
$place = new Place; $place = new Place();
$place->name = Arr::get($checkin, 'properties.name.0'); $place->name = Arr::get($checkin, 'properties.name.0');
$place->external_urls = Arr::get($checkin, 'properties.url.0'); $place->external_urls = Arr::get($checkin, 'properties.url.0');
$place->latitude = Arr::get($checkin, 'properties.latitude.0'); $place->latitude = Arr::get($checkin, 'properties.latitude.0');

30
app/Services/Service.php Normal file
View file

@ -0,0 +1,30 @@
<?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,6 +7,7 @@ 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
{ {
@ -18,7 +19,7 @@ class TokenService
$config = resolve(Configuration::class); $config = resolve(Configuration::class);
$token = $config->builder() $token = $config->builder()
->issuedAt(new DateTimeImmutable) ->issuedAt(new DateTimeImmutable())
->withClaim('client_id', $data['client_id']) ->withClaim('client_id', $data['client_id'])
->withClaim('me', $data['me']) ->withClaim('me', $data['me'])
->withClaim('scope', $data['scope']) ->withClaim('scope', $data['scope'])
@ -29,4 +30,20 @@ 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;
}
} }

51
artisan
View file

@ -1,16 +1,53 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true)); define('LARAVEL_START', microtime(true));
// Register the Composer autoloader... /*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader
| for our application. We just need to utilize it! We'll require it
| into the script here so that we do not have to worry about the
| loading of any of our classes manually. It's great to relax.
|
*/
require __DIR__.'/vendor/autoload.php'; require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command... $app = require_once __DIR__.'/bootstrap/app.php';
$status = (require_once __DIR__.'/bootstrap/app.php')
->handleCommand(new ArgvInput); /*
|--------------------------------------------------------------------------
| Run The Artisan Application
|--------------------------------------------------------------------------
|
| When we run the console application, the current CLI command will be
| executed in this console and the response sent back to a terminal
| or another output device for the developers. Here goes nothing!
|
*/
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
/*
|--------------------------------------------------------------------------
| Shutdown The Application
|--------------------------------------------------------------------------
|
| Once Artisan has finished running, we will fire off the shutdown events
| so that any final work may be done by the application before we shut
| down the process. This is the last thing to happen to the request.
|
*/
$kernel->terminate($input, $status);
exit($status); exit($status);

View file

@ -1,28 +1,55 @@
<?php <?php
use App\Http\Middleware\LinkHeadersMiddleware; /*
use Illuminate\Foundation\Application; |--------------------------------------------------------------------------
use Illuminate\Foundation\Configuration\Exceptions; | Create The Application
use Illuminate\Foundation\Configuration\Middleware; |--------------------------------------------------------------------------
|
| The first thing we will do is create a new Laravel application instance
| which serves as the "glue" for all the components of Laravel, and is
| the IoC container for the system binding all of the various parts.
|
*/
return Application::configure(basePath: dirname(__DIR__)) $app = new Illuminate\Foundation\Application(
->withRouting( $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
web: __DIR__.'/../routes/web.php', );
commands: __DIR__.'/../routes/console.php',
health: '/up', /*
) |--------------------------------------------------------------------------
->withMiddleware(function (Middleware $middleware) { | Bind Important Interfaces
$middleware |--------------------------------------------------------------------------
->append(LinkHeadersMiddleware::class) |
->validateCsrfTokens(except: [ | Next, we need to bind some important interfaces into the container so
'auth', // This is the IndieAuth auth endpoint | we will be able to resolve them when needed. The kernels serve the
'token', // This is the IndieAuth token endpoint | incoming requests to this application from both the web and CLI.
'api/post', |
'api/media', */
'micropub/places',
'webmention', $app->singleton(
]); Illuminate\Contracts\Http\Kernel::class,
}) App\Http\Kernel::class
->withExceptions(function (Exceptions $exceptions) { );
//
})->create(); $app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
);
/*
|--------------------------------------------------------------------------
| Return The Application
|--------------------------------------------------------------------------
|
| This script returns the application instance. The instance is given to
| the calling script so we can separate the building of the instances
| from the actual running of the application and sending responses.
|
*/
return $app;

View file

@ -1,7 +0,0 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\MicropubServiceProvider::class,
];

View file

@ -1,56 +1,66 @@
{ {
"$schema": "https://getcomposer.org/schema.json",
"name": "jonnybarnes/jonnybarnes.uk", "name": "jonnybarnes/jonnybarnes.uk",
"type": "project", "type": "project",
"description": "The code for jonnybarnes.uk, based on Laravel 11", "description": "The code for jonnybarnes.uk, based on Laravel 8",
"keywords": ["laravel", "framework", "indieweb"], "keywords": [
"framework",
"laravel",
"indieweb"
],
"license": "CC0-1.0", "license": "CC0-1.0",
"require": { "require": {
"php": "^8.2", "php": "^8.0",
"ext-dom": "*", "ext-dom": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-json": "*", "ext-json": "*",
"ext-pgsql": "*", "cviebrock/eloquent-sluggable": "^10.0",
"ext-sodium": "*", "guzzlehttp/guzzle": "^7.0.1",
"cviebrock/eloquent-sluggable": "^12.0",
"guzzlehttp/guzzle": "^7.2",
"indieauth/client": "^1.1", "indieauth/client": "^1.1",
"intervention/image": "^3", "intervention/image": "^2.4",
"jonnybarnes/indieweb": "~0.2", "jonnybarnes/indieweb": "~0.2",
"jonnybarnes/webmentions-parser": "~0.5", "jonnybarnes/webmentions-parser": "~0.5",
"jublonet/codebird-php": "4.0.0-beta.1", "jublonet/codebird-php": "4.0.0-beta.1",
"laravel/framework": "^12.0", "laravel/framework": "^10.0",
"laravel/horizon": "^5.0", "laravel/horizon": "^5.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^3.0",
"laravel/scout": "^10.1", "laravel/scout": "^10.1",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.0",
"lcobucci/jwt": "^5.0", "lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0", "league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3", "mf2/mf2": "~0.3",
"phpdocumentor/reflection-docblock": "^5.3",
"spatie/commonmark-highlighter": "^3.0", "spatie/commonmark-highlighter": "^3.0",
"spatie/laravel-ignition": "^2.1", "symfony/html-sanitizer": "^6.1"
"symfony/html-sanitizer": "^7.0",
"symfony/property-access": "^7.0",
"symfony/serializer": "^7.0",
"web-auth/webauthn-lib": "^5.0"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.0", "barryvdh/laravel-debugbar": "^3.0",
"barryvdh/laravel-ide-helper": "^3.0", "barryvdh/laravel-ide-helper": "^2.6",
"beyondcode/laravel-dump-server": "^1.0",
"fakerphp/faker": "^1.9.2", "fakerphp/faker": "^1.9.2",
"laravel/dusk": "^8.0", "laravel/dusk": "^7.0",
"laravel/pail": "^1.2", "laravel/pint": "^1.0.0",
"laravel/pint": "^1.0", "laravel/sail": "^1.15",
"laravel/sail": "^1.18", "mockery/mockery": "^1.0",
"mockery/mockery": "^1.4.4", "nunomaduro/collision": "^7.0",
"nunomaduro/collision": "^8.1", "phpunit/php-code-coverage": "^10.0",
"openai-php/client": "^0.10.1", "phpunit/phpunit": "^10.0",
"phpunit/php-code-coverage": "^11.0", "spatie/laravel-ignition": "^2.0",
"phpunit/phpunit": "^11.0",
"spatie/laravel-ray": "^1.12", "spatie/laravel-ray": "^1.12",
"spatie/x-ray": "^1.2" "vimeo/psalm": "^5.0"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"composer/package-versions-deprecated": false,
"composer/installers": false
}
},
"extra": {
"laravel": {
"dont-discover": []
}
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -64,45 +74,24 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"Tests\\": "tests/" "Tests\\": "tests"
} }
}, },
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": { "scripts": {
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi"
], ],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [ "post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
], ],
"post-create-project-cmd": [ "post-create-project-cmd": [
"@php artisan key:generate --ansi", "@php artisan key:generate --ansi"
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
], ],
"dev": [ "test": [
"Composer\\Config::disableProcessTimeout", "vendor/bin/phpunit --stop-on-failure"
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
] ]
},
"extra": {
"laravel": {
"dont-discover": []
} }
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"composer/installers": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
} }

7854
composer.lock generated

File diff suppressed because it is too large Load diff

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