Compare commits

..
169 changed files with 5208 additions and 5985 deletions

View file

@ -70,6 +70,11 @@ 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
@ -78,8 +83,8 @@ SESSION_SAME_SITE=strict
LOG_SLACK_WEBHOOK_URL=
BRRR_WEBHOOK_URL=
FLARE_KEY=
IGNITION_OPEN_AI_KEY=
BRIDGY_MASTODON_TOKEN=

View file

@ -28,10 +28,10 @@ class MentionRenderer implements NodeRendererInterface
// 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);
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);
return new HtmlElement('a', ['href' => 'https://' . $parts[1] . '/@' . $parts[0]], '@' . $mentionText);
}
}

View file

@ -34,10 +34,10 @@ class CopyMediaToLocal extends Command
foreach ($media as $mediaItem) {
$filename = $mediaItem->path;
$this->info('Processing: '.$filename);
$this->info('Processing: ' . $filename);
// If the file is already saved locally skip to next one
if (Storage::disk('local')->exists('public/'.$filename)) {
if (Storage::disk('local')->exists('public/' . $filename)) {
$this->info('File already exists locally, skipping');
continue;
@ -50,19 +50,19 @@ class CopyMediaToLocal extends Command
continue;
}
$contents = Storage::disk('s3')->get($filename);
Storage::disk('local')->put('public/'.$filename, $contents);
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;
$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));
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));
Storage::disk('local')->put('public/' . $smallFilename, Storage::disk('s3')->get($smallFilename));
}
}
}

View file

@ -1,101 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Media;
use App\Models\Note;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class MigrateMedia extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:migrate-media';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate media';
/**
* Execute the console command.
*/
public function handle(): void
{
// First check new `media_note` table exists
if (! DB::getSchemaBuilder()->hasTable('media_note')) {
$this->error('The table "media_note" does not exist.');
exit(1);
}
// Load all media already saved in `media_endpoint` table
$this->line('Updating existing local media');
$mediaEndpointMedia = Media::all();
// Save relationship in new `media_note` table based on `media_endpoint.note_id`
$this->withProgressBar($mediaEndpointMedia, function (Media $mediaEndpointMediaItem) {
$note = Note::find($mediaEndpointMediaItem->note_id);
if ($note) {
$note->media()->syncWithoutDetaching($mediaEndpointMediaItem->id);
}
});
// Load all media records from `media` table
$this->line('');
$this->line('Migrating old media from S3');
$oldMedia = DB::table('media')->get();
foreach ($oldMedia as $oldMediaItem) {
// We only want to process the S3 media
if ($oldMediaItem->disk !== 's3') {
$this->warn('Original media item never stored in S3');
continue;
}
// Check media exists in S3
if (! Storage::disk('s3')->exists($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) {
$this->warn('Original media item not found in S3');
continue;
}
// We want to just copy the file, check it does not already exist locally
if (Storage::disk('public')->exists('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) {
$this->warn('File already exists locally with filename of original media item');
continue;
}
// Save relationship based on `media.model_id`
// I have already checked they are all notes
$noteId = $oldMediaItem->model_id;
$note = Note::find($noteId);
if (! $note) {
$this->warn('Note no longer exists');
continue;
}
// Create media entry in database and attach to note
$newMediaItem = Media::create([
'path' => 'media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name,
'type' => 'image',
]);
$note->media()->syncWithoutDetaching($newMediaItem->id);
// Copy the file
Storage::disk('public')->writeStream('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name, Storage::disk('s3')->readStream($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name));
$this->info('Media item migrated from S3');
}
$this->line('');
$this->line('Migration finished');
}
}

View file

@ -63,7 +63,7 @@ class MigratePlaceDataFromPostgis extends Command
$places = Place::all();
$places->each(function ($place) {
$this->info('Extracting Postgis data for place: '.$place->name);
$this->info('Extracting Postgis data for place: ' . $place->name);
$place->latitude = $place->location->getLat();
$place->longitude = $place->location->getLng();

View file

@ -32,11 +32,11 @@ class ParseCachedWebMentions extends Command
*/
public function handle(FileSystem $filesystem): void
{
$htmlFiles = $filesystem->allFiles(storage_path().'/HTML');
$htmlFiles = $filesystem->allFiles(storage_path() . '/HTML');
foreach ($htmlFiles as $file) {
if ($file->getExtension() !== 'backup') { // we dont want to parse `.backup` files
$filepath = $file->getPathname();
$this->info('Loading HTML from: '.$filepath);
$this->info('Loading HTML from: ' . $filepath);
$html = $filesystem->get($filepath);
$url = $this->urlFromFilename($filepath);
$webmention = WebMention::where('source', $url)->firstOrFail();
@ -53,7 +53,7 @@ class ParseCachedWebMentions extends Command
*/
private function urlFromFilename(string $filepath): string
{
$dir = mb_substr($filepath, mb_strlen(storage_path().'/HTML/'));
$dir = mb_substr($filepath, mb_strlen(storage_path() . '/HTML/'));
$url = str_replace(['http/', 'https/'], ['http://', 'https://'], $dir);
if (mb_substr($url, -10) === 'index.html') {
$url = mb_substr($url, 0, -10);

View file

@ -31,7 +31,7 @@ class ReDownloadWebMentions extends Command
{
$webmentions = WebMention::all();
foreach ($webmentions as $webmention) {
$this->info('Initiation re-download of '.$webmention->source);
$this->info('Initiation re-download of ' . $webmention->source);
dispatch(new DownloadWebMention($webmention->source));
}
}

View file

@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Media;
use Illuminate\Console\Command;
use Illuminate\Image\ImageException;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
class ReprocessMediaImages extends Command
{
protected $signature = 'app:reprocess-media-images {--dry-run : List images that would be processed without making changes}';
protected $description = 'Regenerate medium and small image variants using correct aspect-ratio scaling';
public function handle(): void
{
$media = Media::where('type', 'image')
->whereNotNull('image_widths')
->where('image_widths', '>', 1000)
->get();
$dryRun = $this->option('dry-run');
$this->info("Found {$media->count()} images to reprocess.".($dryRun ? ' (dry run)' : ''));
foreach ($media as $item) {
$path = $item->path;
if (! Storage::disk('public')->exists($path)) {
$this->warn("{$path}: original not found on public disk, skipping.");
continue;
}
if ($dryRun) {
$this->line("{$path} ({$item->image_widths}px wide)");
continue;
}
$this->info("Processing: {$path}");
$image = Image::fromStorage($path, 'public');
try {
$image->width();
} catch (ImageException) {
$this->warn(' Could not decode image, skipping.');
continue;
}
$filenameParts = explode('.', $path);
$extension = array_pop($filenameParts);
$basename = trim(implode('.', $filenameParts), '.');
Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes());
Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes());
$this->info(' Done.');
}
$this->info('Reprocessing complete.'.($dryRun ? ' (dry run — no files were changed)' : ''));
}
}

View file

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

View file

@ -6,11 +6,10 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Contact;
use GuzzleHttp\Client;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
class ContactsController extends Controller
@ -76,7 +75,7 @@ class ContactsController extends Controller
if (request()->hasFile('avatar') && (request()->input('homepage') != '')) {
$dir = parse_url(request()->input('homepage'), PHP_URL_HOST);
$destination = public_path().'/assets/profile-images/'.$dir;
$destination = public_path() . '/assets/profile-images/' . $dir;
$filesystem = new Filesystem;
if ($filesystem->isDirectory($destination) === false) {
$filesystem->makeDirectory($destination);
@ -104,7 +103,7 @@ class ContactsController extends Controller
* This method attempts to find the microformat marked-up profile image
* from a given homepage and save it accordingly
*
* @return RedirectResponse|View
* @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
*/
public function getAvatar(int $contactId)
{
@ -113,13 +112,14 @@ class ContactsController extends Controller
$avatar = null;
$contact = Contact::findOrFail($contactId);
if ($contact->homepage !== null && mb_strlen($contact->homepage) !== 0) {
$client = resolve(Client::class);
try {
$response = Http::throw()->get($contact->homepage);
} catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit')
$response = $client->get($contact->homepage);
} catch (\GuzzleHttp\Exception\BadResponseException $e) {
return redirect('/admin/contacts/' . $contactId . '/edit')
->with('error', 'Bad resposne from contacts homepage');
}
$mf2 = \Mf2\parse($response->body(), $contact->homepage);
$mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage);
foreach ($mf2['items'] as $microformat) {
if (Arr::get($microformat, 'type.0') === 'h-card') {
$avatarURL = Arr::get($microformat, 'properties.photo.0.value');
@ -128,19 +128,19 @@ class ContactsController extends Controller
}
if ($avatarURL !== null) {
try {
$avatar = Http::throw()->get($avatarURL);
} catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit')
$avatar = $client->get($avatarURL);
} catch (\GuzzleHttp\Exception\BadResponseException $e) {
return redirect('/admin/contacts/' . $contactId . '/edit')
->with('error', 'Unable to download avatar');
}
}
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;
if ($filesystem->isDirectory($directory) === false) {
$filesystem->makeDirectory($directory);
}
$filesystem->put($directory.'/image', $avatar->body());
$filesystem->put($directory . '/image', $avatar->getBody());
return view('admin.contacts.getavatarsuccess', [
'homepage' => parse_url($contact->homepage, PHP_URL_HOST),
@ -148,6 +148,6 @@ class ContactsController extends Controller
}
}
return redirect('/admin/contacts/'.$contactId.'/edit');
return redirect('/admin/contacts/' . $contactId . '/edit');
}
}

View file

@ -128,7 +128,7 @@ class PasskeysController extends Controller
// 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');
throw new WebAuthnException('No public key credential request options found');
}
$attestationStatementSupportManager = new AttestationStatementSupportManager;
@ -145,7 +145,7 @@ class PasskeysController extends Controller
);
if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
throw new WebauthnException('Invalid response type');
throw new WebAuthnException('Invalid response type');
}
$algorithmManager = new Manager;

View file

@ -120,7 +120,6 @@ class PlacesController extends Controller
foreach ($place1->notes as $note) {
$note->place()->dissociate();
$note->place()->associate($place2->id);
$note->save();
}
$place1->delete();
}
@ -128,7 +127,6 @@ class PlacesController extends Controller
foreach ($place2->notes as $note) {
$note->place()->dissociate();
$note->place()->associate($place1->id);
$note->save();
}
$place2->delete();
}

View file

@ -38,9 +38,9 @@ class ArticlesController extends Controller
if ($article->updated_at->year != $year || $article->updated_at->month != $month) {
return redirect('/blog/'
.$article->updated_at->year
.'/'.$article->updated_at->format('m')
.'/'.$slug);
. $article->updated_at->year
. '/' . $article->updated_at->format('m')
. '/' . $slug);
}
return view('articles.show', compact('article'));

View file

@ -19,9 +19,9 @@ class ContactsController extends Controller
$contacts = Contact::all();
foreach ($contacts as $contact) {
$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';
$contact->image = ($filesystem->exists($file)) ?
'/assets/profile-images/'.$contact->homepageHost.'/image'
'/assets/profile-images/' . $contact->homepageHost . '/image'
:
'/assets/profile-images/default-image';
}
@ -35,11 +35,11 @@ class ContactsController extends Controller
public function show(Contact $contact): View
{
$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;
$image = ($filesystem->exists($file)) ?
'/assets/profile-images/'.$contact->homepageHost.'/image'
'/assets/profile-images/' . $contact->homepageHost . '/image'
:
'/assets/profile-images/default-image';

View file

@ -71,9 +71,9 @@ class FeedsController extends Controller
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$data = [
'version' => 'https://jsonfeed.org/version/1.1',
'title' => 'The JSON Feed for '.config('user.display_name').'s blog',
'home_page_url' => config('app.url').'/blog',
'feed_url' => config('app.url').'/blog/feed.json',
'title' => 'The JSON Feed for ' . config('user.display_name') . 's blog',
'home_page_url' => config('app.url') . '/blog',
'feed_url' => config('app.url') . '/blog/feed.json',
'authors' => [
[
'name' => config('user.display_name'),
@ -85,9 +85,9 @@ class FeedsController extends Controller
foreach ($articles as $key => $article) {
$data['items'][$key] = [
'id' => config('app.url').$article->link,
'id' => config('app.url') . $article->link,
'title' => $article->title,
'url' => config('app.url').$article->link,
'url' => config('app.url') . $article->link,
'content_html' => $article->main,
'date_published' => $article->created_at->tz('UTC')->toRfc3339String(),
'date_modified' => $article->updated_at->tz('UTC')->toRfc3339String(),
@ -105,9 +105,9 @@ class FeedsController extends Controller
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
$data = [
'version' => 'https://jsonfeed.org/version/1.1',
'title' => 'The JSON Feed for '.config('user.display_name').'s notes',
'home_page_url' => config('app.url').'/notes',
'feed_url' => config('app.url').'/notes/feed.json',
'title' => 'The JSON Feed for ' . config('user.display_name') . 's notes',
'home_page_url' => config('app.url') . '/notes',
'feed_url' => config('app.url') . '/notes/feed.json',
'authors' => [
[
'name' => config('user.display_name'),
@ -144,8 +144,8 @@ class FeedsController extends Controller
$items[] = [
'type' => 'entry',
'published' => $article->created_at,
'uid' => config('app.url').$article->link,
'url' => config('app.url').$article->link,
'uid' => config('app.url') . $article->link,
'url' => config('app.url') . $article->link,
'content' => [
'text' => $article->main,
'html' => $article->html,
@ -156,7 +156,7 @@ class FeedsController extends Controller
return response()->json([
'type' => 'feed',
'name' => 'Blog feed for '.config('app.name'),
'name' => 'Blog feed for ' . config('app.name'),
'url' => url('/blog'),
'author' => [
'type' => 'card',
@ -192,7 +192,7 @@ class FeedsController extends Controller
return response()->json([
'type' => 'feed',
'name' => 'Notes feed for '.config('app.name'),
'name' => 'Notes feed for ' . config('app.name'),
'url' => url('/notes'),
'author' => [
'type' => 'card',

View file

@ -5,12 +5,13 @@ 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\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View;
use Random\RandomException;
@ -198,13 +199,15 @@ class IndieAuthController extends Controller
}
// Otherwise we need to check the redirect_uri is in the client_id's redirect_uris
$guzzle = resolve(Client::class);
try {
$clientInfo = Http::throw()->get($clientId);
} catch (\Throwable) {
$clientInfo = $guzzle->get($clientId);
} catch (Exception) {
return false;
}
$clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId);
$clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId);
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];

View file

@ -6,13 +6,10 @@ namespace App\Http\Controllers;
use App\Exceptions\InvalidTokenScopeException;
use App\Exceptions\MicropubHandlerException;
use App\Exceptions\MicropubUnsupportedModelException;
use App\Http\Requests\MicropubRequest;
use App\Models\Place;
use App\Models\SyndicationTarget;
use App\Services\Micropub\Data\MicropubData;
use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Lcobucci\JWT\Token;
@ -29,9 +26,9 @@ class MicropubController extends Controller
/**
* Respond to a POST request to the micropub endpoint.
*
* MicropubRequest detects the request type (e.g. entry, card, update).
* The handler registry resolves the appropriate handler, whose DTO
* extracts the relevant fields from the request via fromRequest().
* 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
{
@ -46,36 +43,13 @@ class MicropubController extends Controller
try {
$handler = $this->handlerRegistry->getHandler($type);
$dataClass = $handler->dataClass();
/** @var MicropubData $data */
$data = $dataClass::fromRequest($request);
$result = $handler->handle($data);
if ($result['response'] === 'updated') {
return response()->json([
'response' => $result['response'],
], 200)->header('Location', $result['url']);
}
$result = $handler->handle($request->getMicropubData());
// Return appropriate response based on the handler result
return response()->json([
'response' => $result['response'],
'location' => $result['url'] ?? null,
], 201)->header('Location', $result['url']);
} catch (InvalidTokenScopeException) {
return response()->json([
'error' => 'insufficient_scope',
'error_description' => 'The token does not have the required scope for this request',
], 401);
} catch (ModelNotFoundException) {
return response()->json([
'error' => 'invalid_request',
'error_description' => 'No known note with given ID',
], 404);
} catch (MicropubUnsupportedModelException) {
return response()->json([
'error' => 'invalid',
'error_description' => 'This implementation currently only supports the updating of notes',
], 500);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'invalid_request',
@ -83,10 +57,15 @@ class MicropubController extends Controller
], 400);
} catch (MicropubHandlerException) {
return response()->json([
'error' => 'unsupported_operation',
'error' => 'Unknown Micropub type',
'error_description' => 'The request could not be processed by this server',
], 500);
} catch (\Exception $e) {
} 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',
@ -97,9 +76,10 @@ class MicropubController extends Controller
/**
* Respond to a GET request to the micropub endpoint.
*
* Token validation is handled by the VerifyMicropubToken middleware.
* Supports q=syndicate-to, q=config, and q=geo:<lat>,<lng> queries.
* The default response returns the token metadata.
* A GET request has been made to `api/post` with an accompanying
* token, here we check whether the token is valid and respond
* appropriately. Further if the request has the query parameter
* syndicate-to we respond with the known syndication endpoints.
*/
public function get(Request $request): JsonResponse
{

View file

@ -13,10 +13,9 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Image\ImageException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use Ramsey\Uuid\Uuid;
class MicropubMediaController extends Controller
@ -112,9 +111,12 @@ class MicropubMediaController extends Controller
$filename = Storage::disk('local')->putFile('media', $file);
/** @var ImageManager $manager */
$manager = resolve(ImageManager::class);
try {
$width = Image::fromUpload($request->file('file'))->width();
} catch (ImageException) {
$image = $manager->read($request->file('file'));
$width = $image->width();
} catch (Exception) {
// not an image
$width = null;
}
@ -191,7 +193,7 @@ class MicropubMediaController extends Controller
*/
private function saveFileToLocal(UploadedFile $file): string
{
$filename = Uuid::uuid4()->toString().'.'.$file->extension();
$filename = Uuid::uuid4()->toString() . '.' . $file->extension();
Storage::disk('local')->putFileAs('', $file, $filename);
return $filename;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Note;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
@ -52,7 +53,7 @@ class NotesController extends Controller
->withCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}])->firstOrFail();
} catch (\Exception) {
} catch (ModelNotFoundException $exception) {
abort(404);
}
@ -64,7 +65,7 @@ class NotesController extends Controller
*/
public function redirect(int $decId): RedirectResponse
{
return redirect(config('app.url').'/notes/'.(new Numbers)->numto60($decId));
return redirect(config('app.url') . '/notes/' . (new Numbers)->numto60($decId));
}
/**

View file

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

View file

@ -16,7 +16,7 @@ class RedirectIfAuthenticated
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{

View file

@ -5,9 +5,12 @@ 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 [
@ -15,25 +18,102 @@ class MicropubRequest extends FormRequest
];
}
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()) {
$data = $this->json()->all();
$this->normalizeMicropubJson();
} else {
$this->normalizeMicropubForm();
}
}
if (isset($data['action']) && $data['action'] === 'update') {
return 'update';
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 (str_starts_with($type, 'h-')) {
$this->micropubData['type'] = substr($type, 2);
}
}
// Or set the type to update
elseif (isset($data['action']) && $data['action'] === 'update') {
$this->micropubData['type'] = 'update';
}
if (isset($data['type']) && is_array($data['type'])) {
$type = current($data['type']);
if (str_starts_with($type, 'h-')) {
return substr($type, 2);
}
}
// 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'] = $this->getLocationData($data);
$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;
}
}
private function getLocationData(array $data): array|string|null
{
if (! Arr::has($data, 'properties.location')) {
return null;
}
return $this->input('h') ?: null;
if (Arr::has($data, 'properties.location.0')) {
return Arr::get($data, 'properties.location.0');
}
return Arr::get($data, 'properties.location');
}
}

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Jobs;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\FileSystem\FileSystem;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class DownloadWebMention implements ShouldQueue
{
@ -29,19 +29,19 @@ class DownloadWebMention implements ShouldQueue
/**
* Execute the job.
*
* @throws RequestException
* @throws GuzzleException
* @throws FileNotFoundException
*/
public function handle(): void
public function handle(Client $guzzle): void
{
// 4XX and 5XX responses should throw so Laravel can catch and
// retry these automatically.
$response = Http::throw()->get($this->source);
if ($response->status() === 200) {
$response = $guzzle->request('GET', $this->source);
// 4XX and 5XX responses should get Guzzle to throw an exception,
// Laravel should catch and retry these automatically.
if ($response->getStatusCode() === 200) {
$filesystem = new FileSystem;
$filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source);
$filename = storage_path('HTML') . '/' . $this->createFilenameFromURL($this->source);
// backup file first
$filenameBackup = $filename.'.'.date('Y-m-d').'.backup';
$filenameBackup = $filename . '.' . date('Y-m-d') . '.backup';
if ($filesystem->exists($filename)) {
$filesystem->copy($filename, $filenameBackup);
}
@ -56,7 +56,7 @@ class DownloadWebMention implements ShouldQueue
// save new HTML
$filesystem->put(
$filename,
$response->body()
(string) $response->getBody()
);
// remove backup if the same
if ($filesystem->exists($filenameBackup)) {

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\WebMention;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class NotifyBrrrOfWebMention implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
protected WebMention $webMention
) {}
/**
* Execute the job.
*/
public function handle(): void
{
$webhookUrl = config('services.brrr.webhook_url');
if (blank($webhookUrl)) {
return;
}
Http::post($webhookUrl, [
'title' => $this->title(),
'message' => "From {$this->webMention->source}",
'open_url' => $this->webMention->target,
]);
}
/**
* Build a notification title based on the webmention type.
*/
private function title(): string
{
return match ($this->webMention->type) {
'in-reply-to' => 'New reply',
'like-of' => 'New like',
'repost-of' => 'New repost',
default => 'New webmention',
};
}
}

View file

@ -5,13 +5,16 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Like;
use Codebird\Codebird;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
@ -31,11 +34,42 @@ class ProcessLike implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Authorship $authorship): int
public function handle(Client $client, Authorship $authorship): int
{
$response = Http::throw()->get($this->like->url);
$mf2 = \Mf2\parse($response->body(), $this->like->url);
if ($this->isTweet($this->like->url)) {
$codebird = resolve(Codebird::class);
$tweet = $codebird->statuses_oembed(['url' => $this->like->url]);
$this->like->author_name = $tweet->author_name;
$this->like->author_url = $tweet->author_url;
$this->like->content = $tweet->html;
$this->like->save();
// POSSE like
try {
$client->request(
'POST',
'https://brid.gy/publish/webmention',
[
'form_params' => [
'source' => $this->like->url,
'target' => 'https://brid.gy/publish/twitter',
],
]
);
} catch (RequestException) {
return 0;
}
return 0;
}
$response = $client->request('GET', $this->like->url);
$mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url);
if (Arr::has($mf2, 'items.0.properties.content')) {
$this->like->content = $mf2['items'][0]['properties']['content'][0]['html'];
}
@ -57,4 +91,15 @@ class ProcessLike implements ShouldQueue
return 0;
}
/**
* Determine if a given URL is that of a Tweet.
*/
private function isTweet(string $url): bool
{
$host = parse_url($url, PHP_URL_HOST);
$parts = array_reverse(explode('.', $host));
return $parts[0] === 'com' && $parts[1] === 'twitter';
}
}

View file

@ -7,11 +7,11 @@ namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Image\ImageException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Exceptions\DecoderException;
use Intervention\Image\ImageManager;
class ProcessMedia implements ShouldQueue
{
@ -30,38 +30,40 @@ class ProcessMedia implements ShouldQueue
/**
* Execute the job.
*/
public function handle(): void
public function handle(ImageManager $manager): void
{
// Load file
$file = Storage::disk('local')->get($this->filename);
$file = Storage::disk('local')->get('media/' . $this->filename);
// Open file
$image = Image::fromStorage($this->filename, 'local');
try {
$width = $image->width();
} catch (ImageException) {
$image = $manager->read($file);
} catch (DecoderException) {
// not an image; delete file and end job
Storage::disk('local')->delete($this->filename);
Storage::disk('local')->delete('media/' . $this->filename);
return;
}
// Save the file publicly
Storage::disk('public')->put($this->filename, $file);
Storage::disk('public')->put('media/' . $this->filename, $file);
// Create smaller versions if necessary
if ($width > 1000) {
if ($image->width() > 1000) {
$filenameParts = explode('.', $this->filename);
$extension = array_pop($filenameParts);
// the following achieves this data flow
// foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
$basename = trim(implode('.', $filenameParts), '.');
Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes());
Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes());
$medium = $image->resize(width: 1000);
Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode());
$small = $image->resize(width: 500);
Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode());
}
// Now we can delete the locally saved image
Storage::disk('local')->delete($this->filename);
Storage::disk('local')->delete('media/' . $this->filename);
}
}

View file

@ -7,12 +7,13 @@ namespace App\Jobs;
use App\Exceptions\RemoteContentNotFoundException;
use App\Models\Note;
use App\Models\WebMention;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException;
use Jonnybarnes\WebmentionsParser\Parser;
use Mf2;
@ -35,20 +36,18 @@ class ProcessWebMention implements ShouldQueue
* Execute the job.
*
* @throws RemoteContentNotFoundException
* @throws GuzzleException
* @throws InvalidMentionException
*/
public function handle(Parser $parser): void
public function handle(Parser $parser, Client $guzzle): void
{
try {
$response = Http::get($this->source);
} catch (ConnectionException) {
$response = $guzzle->request('GET', $this->source);
} catch (RequestException $e) {
throw new RemoteContentNotFoundException;
}
if ($response->failed()) {
throw new RemoteContentNotFoundException;
}
$this->saveRemoteContent($response->body(), $this->source);
$microformats = Mf2\parse($response->body(), $this->source);
$this->saveRemoteContent((string) $response->getBody(), $this->source);
$microformats = Mf2\parse((string) $response->getBody(), $this->source);
$webmentions = WebMention::where('source', $this->source)->get();
foreach ($webmentions as $webmention) {
// check webmention still references target
@ -96,7 +95,6 @@ class ProcessWebMention implements ShouldQueue
$webmention->type = $type;
$webmention->mf2 = json_encode($microformats);
$webmention->save();
dispatch(new NotifyBrrrOfWebMention($webmention));
}
/**
@ -112,7 +110,7 @@ class ProcessWebMention implements ShouldQueue
if (str_ends_with($url, '/')) {
$filenameFromURL .= 'index.html';
}
$path = storage_path().'/HTML/'.$filenameFromURL;
$path = storage_path() . '/HTML/' . $filenameFromURL;
$parts = explode('/', $path);
$name = array_pop($parts);
$dir = implode('/', $parts);

View file

@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Jobs;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
@ -56,18 +55,20 @@ class SaveProfileImage implements ShouldQueue
&& parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com'
&& parse_url($photo, PHP_URL_HOST) !== 'twitter.com'
) {
$client = resolve(Client::class);
try {
$response = Http::throw()->get($photo);
$image = $response->body();
} catch (ConnectionException|RequestException) {
$response = $client->get($photo);
$image = $response->getBody();
} catch (RequestException) {
// we are opening and reading the default image so that
$default = public_path().'/assets/profile-images/default-image';
$default = public_path() . '/assets/profile-images/default-image';
$handle = fopen($default, 'rb');
$image = fread($handle, filesize($default));
fclose($handle);
}
$path = public_path().'/assets/profile-images/'.parse_url($home, PHP_URL_HOST).'/image';
$path = public_path() . '/assets/profile-images/' . parse_url($home, PHP_URL_HOST) . '/image';
$parts = explode('/', $path);
$name = array_pop($parts);
$dir = implode('/', $parts);

View file

@ -5,15 +5,14 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Bookmark;
use GuzzleHttp\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use JsonException;
class SaveScreenshot implements ShouldQueue
{
@ -28,68 +27,77 @@ class SaveScreenshot implements ShouldQueue
/**
* Execute the job.
*
*
* @throws JsonException
*/
public function handle(): void
{
$cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2')
->withToken(config('services.cloudconvert.token'))
->throw();
// A normal Guzzle client
$client = resolve(Client::class);
// A Guzzle client with a custom Middleware to retry the CloudConvert API requests
$retryClient = resolve('RetryGuzzle');
// First request that CloudConvert takes a screenshot of the URL
$takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [
'url' => $this->bookmark->url,
'output_format' => 'png',
'screen_width' => 1440,
'screen_height' => 900,
'wait_until' => 'networkidle0',
'wait_time' => 100,
$takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'json' => [
'url' => $this->bookmark->url,
'output_format' => 'png',
'screen_width' => 1440,
'screen_height' => 900,
'wait_until' => 'networkidle0',
'wait_time' => 100,
],
]);
$taskId = $takeScreenshotJobResponse->json('data.id');
$taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
// Now wait till the status job is finished
$screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId);
$finishedCaptureId = $screenshotJobStatusResponse->json('data.id');
// Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from
$exportImageJob = $cloudConvert->post('/export/url', [
'input' => $finishedCaptureId,
'archive_multiple_files' => false,
$screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $taskId, [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
$exportImageJobId = $exportImageJob->json('data.id');
$finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
// Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from
$exportImageJob = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'json' => [
'input' => $finishedCaptureId,
'archive_multiple_files' => false,
],
]);
$exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
// Again, wait till the status of this export job is finished
$finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId);
$finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $exportImageJobId, [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
// Now we can download the screenshot and save it to the storage
$finalImageUrl = $finalImageUrlResponse->json('data.result.files.0.url');
$finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url;
$finalImageUrlContent = Http::throw()->get($finalImageUrl);
$finalImageUrlContent = $client->request('GET', $finalImageUrl);
Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->body());
Storage::disk('public')->put('/assets/img/bookmarks/' . $taskId . '.png', $finalImageUrlContent->getBody()->getContents());
$this->bookmark->screenshot = $taskId;
$this->bookmark->save();
}
/**
* Poll a CloudConvert task until it reports a "finished" status.
*/
private function pollUntilFinished(PendingRequest $client, string $taskId): Response
{
$attempts = 0;
do {
$response = $client->get('/tasks/'.$taskId, ['include' => 'payload']);
$finished = $response->json('data.status') === 'finished';
if (! $finished) {
$attempts++;
usleep(1_000_000); // 1 second, matches CloudConvert's own polling guidance
}
} while (! $finished && $attempts < 5);
return $response;
}
}

View file

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Header;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils;
@ -12,9 +14,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Mf2\Parser;
class SendWebMentions implements ShouldQueue
{
@ -31,6 +31,8 @@ class SendWebMentions implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(): void
{
@ -40,9 +42,12 @@ class SendWebMentions implements ShouldQueue
foreach ($urls as $url) {
$endpoint = $this->discoverWebmentionEndpoint($url);
if ($endpoint !== null) {
Http::asForm()->post($endpoint, [
'source' => $this->note->uri,
'target' => $url,
$guzzle = resolve(Client::class);
$guzzle->post($endpoint, [
'form_params' => [
'source' => $this->note->uri,
'target' => $url,
],
]);
}
}
@ -50,6 +55,8 @@ class SendWebMentions implements ShouldQueue
/**
* Discover if a URL has a webmention endpoint.
*
* @throws GuzzleException
*/
public function discoverWebmentionEndpoint(string $url): ?string
{
@ -63,9 +70,10 @@ class SendWebMentions implements ShouldQueue
$endpoint = null;
$response = Http::get($url);
$guzzle = resolve(Client::class);
$response = $guzzle->get($url);
// check HTTP Headers for webmention endpoint
$links = Header::parse($response->header('Link'));
$links = Header::parse($response->getHeader('Link'));
foreach ($links as $link) {
if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) {
return $this->resolveUri(trim($link[0], '<>'), $url);
@ -73,13 +81,9 @@ class SendWebMentions implements ShouldQueue
}
// failed to find a header so parse HTML
$html = $response->body();
$html = (string) $response->getBody();
if ($html === '') {
return null;
}
$mf2 = new Parser($html, $url);
$mf2 = new \Mf2\Parser($html, $url);
$rels = $mf2->parseRelsAndAlternates();
if (array_key_exists('webmention', $rels[0])) {
$endpoint = $rels[0]['webmention'][0];

View file

@ -5,22 +5,18 @@ 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;
use Illuminate\Support\Facades\Http;
class SyndicateNoteToBluesky implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Only try once retrying would send Bridgy a duplicate publish webmention.
*/
public int $tries = 1;
/**
* Create a new job instance.
*/
@ -30,26 +26,37 @@ class SyndicateNoteToBluesky implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(): void
public function handle(Client $guzzle): void
{
// no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
$response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
'source' => $this->note->uri,
'target' => 'https://brid.gy/publish/bluesky',
]);
$body = $response->json();
if ($response->status() === 201) {
$this->note->bluesky_url = $body['url'];
$this->note->save();
// We can only make the request if we have an access token
if (config('bridgy.bluesky_token') === null) {
return;
}
throw new \RuntimeException(
'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body())
// 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

@ -5,22 +5,18 @@ 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;
use Illuminate\Support\Facades\Http;
class SyndicateNoteToMastodon implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Only try once retrying would send Bridgy a duplicate publish webmention.
*/
public int $tries = 1;
/**
* Create a new job instance.
*/
@ -30,26 +26,38 @@ class SyndicateNoteToMastodon implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(): void
public function handle(Client $guzzle): void
{
// no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
$response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
'source' => $this->note->uri,
'target' => 'https://brid.gy/publish/mastodon',
]);
$body = $response->json();
if ($response->status() === 201) {
$this->note->mastodon_url = $body['url'];
$this->note->save();
// We can only make the request if we have an access token
if (config('bridgy.mastodon_token') === null) {
return;
}
throw new \RuntimeException(
'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body())
// Make micropub request
$response = $guzzle->request(
'POST',
'https://brid.gy/micropub',
[
'headers' => [
'Authorization' => 'Bearer ' . config('bridgy.mastodon_token'),
],
'json' => [
'type' => ['h-entry'],
'properties' => [
'content' => [$this->note->getRawOriginal('note')],
],
],
]
);
// Parse for syndication URL
if ($response->getStatusCode() === 201) {
$mastodonUrl = $response->getHeader('Location')[0];
$this->note->mastodon_url = $mastodonUrl;
$this->note->save();
}
}
}

View file

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Models;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -14,17 +12,29 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode;
use League\CommonMark\MarkdownConverter;
use Tempest\Highlight\CommonMark\HighlightExtension;
use Spatie\CommonMarkHighlighter\FencedCodeRenderer;
use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
#[Table('articles')]
#[Fillable(['url', 'title', 'main', 'published'])]
class Article extends Model
{
use HasFactory;
use Sluggable;
use SoftDeletes;
/** @var string */
protected $table = 'articles';
/** @var array<int, string> */
protected $fillable = [
'url',
'title',
'main',
'published',
];
/** @var array<string, string> */
protected $casts = [
'created_at' => 'datetime',
@ -50,7 +60,8 @@ class Article extends Model
get: function () {
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new HighlightExtension);
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer);
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer);
$markdownConverter = new MarkdownConverter($environment);
return $markdownConverter->convert($this->main)->getContent();
@ -89,7 +100,7 @@ class Article extends Model
protected function link(): Attribute
{
return Attribute::get(
get: fn () => '/blog/'.$this->updated_at->year.'/'.$this->updated_at->format('m').'/'.$this->titleurl,
get: fn () => '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl,
);
}
@ -101,15 +112,15 @@ class Article extends Model
if ($year === null) {
return $query;
}
$start = $year.'-01-01 00:00:00';
$end = ($year + 1).'-01-01 00:00:00';
$start = $year . '-01-01 00:00:00';
$end = ($year + 1) . '-01-01 00:00:00';
if (($month !== null) && ($month !== 12)) {
$start = $year.'-'.$month.'-01 00:00:00';
$end = $year.'-'.($month + 1).'-01 00:00:00';
$start = $year . '-' . $month . '-01 00:00:00';
$end = $year . '-' . ($month + 1) . '-01 00:00:00';
}
if ($month === 12) {
$start = $year.'-12-01 00:00:00';
$end = ($year + 1).'-01-01 00:00:00';
$start = $year . '-12-01 00:00:00';
$end = ($year + 1) . '-01-01 00:00:00';
}
return $query->where([

View file

@ -4,17 +4,18 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['url', 'name', 'content'])]
class Bookmark extends Model
{
use HasFactory;
/** @var array<int, string> */
protected $fillable = ['url', 'name', 'content'];
/** @var array<string, string> */
protected $casts = [
'syndicates' => 'array',
@ -25,10 +26,10 @@ class Bookmark extends Model
return $this->belongsToMany('App\Models\Tag');
}
protected function localUri(): Attribute
protected function local_uri(): Attribute
{
return Attribute::get(
get: fn () => config('app.url').'/bookmarks/'.$this->id,
get: fn () => config('app.url') . '/bookmarks/' . $this->id,
);
}
}

View file

@ -4,26 +4,28 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Table('contacts')]
#[Fillable(['nick', 'name', 'homepage', 'twitter', 'facebook'])]
class Contact extends Model
{
use HasFactory;
/** @var string */
protected $table = 'contacts';
/** @var array<int, string> */
protected $fillable = ['nick', 'name', 'homepage', 'twitter', 'facebook'];
protected function photo(): Attribute
{
$photo = '/assets/profile-images/default-image';
if (array_key_exists('homepage', $this->attributes) && ! empty($this->attributes['homepage'])) {
$host = parse_url($this->attributes['homepage'], PHP_URL_HOST);
if (file_exists(public_path().'/assets/profile-images/'.$host.'/image')) {
$photo = '/assets/profile-images/'.$host.'/image';
if (file_exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
$photo = '/assets/profile-images/' . $host . '/image';
}
}

View file

@ -5,19 +5,20 @@ declare(strict_types=1);
namespace App\Models;
use App\Traits\FilterHtml;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Mf2;
#[Fillable(['url'])]
class Like extends Model
{
use FilterHtml;
use HasFactory;
/** @var array<int, string> */
protected $fillable = ['url'];
protected function url(): Attribute
{
return Attribute::set(

View file

@ -4,25 +4,25 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
#[Table('media_endpoint')]
#[Fillable(['token', 'path', 'type', 'image_widths'])]
class Media extends Model
{
use HasFactory;
public function notes(): BelongsToMany
/** @var string */
protected $table = 'media_endpoint';
/** @var array<int, string> */
protected $fillable = ['token', 'path', 'type', 'image_widths'];
public function note(): BelongsTo
{
return $this->belongsToMany(Note::class)
->withPivot('alt_text', 'order')
->withTimestamps();
return $this->belongsTo(Note::class);
}
protected function url(): Attribute
@ -33,7 +33,7 @@ class Media extends Model
return $attributes['path'];
}
return config('app.url').'/storage/'.$attributes['path'];
return config('app.url') . '/storage/' . $attributes['path'];
}
);
}
@ -78,7 +78,7 @@ class Media extends Model
$basename = $this->getBasename($path);
$extension = $this->getExtension($path);
return config('app.url').'/storage/'.$basename.'-'.$size.'.'.$extension;
return config('app.url') . '/storage/' . $basename . '-' . $size . '.' . $extension;
}
private function getBasename(string $path): string
@ -89,7 +89,7 @@ class Media extends Model
array_pop($filenameParts);
return ltrim(array_reduce($filenameParts, static function ($carry, $item) {
return $carry.'.'.$item;
return $carry . '.' . $item;
}, ''), '.');
}

View file

@ -4,18 +4,20 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Table('clients')]
#[Fillable(['client_url', 'client_name'])]
class MicropubClient extends Model
{
use HasFactory;
/** @var string */
protected $table = 'clients';
/** @var array<int, string> */
protected $fillable = ['client_url', 'client_name'];
public function notes(): HasMany
{
return $this->hasMany('App\Models\Note', 'client_id', 'client_url');

View file

@ -6,35 +6,32 @@ namespace App\Models;
use App\CommonMark\Generators\MentionGenerator;
use App\CommonMark\Renderers\MentionRenderer;
use App\Observers\NoteObserver;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Table;
use Codebird\Codebird;
use Exception;
use GuzzleHttp\Client;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\IndieWeb\Numbers;
use Laravel\Scout\Searchable;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode;
use League\CommonMark\Extension\Mention\Mention;
use League\CommonMark\Extension\Mention\MentionExtension;
use League\CommonMark\MarkdownConverter;
use Normalizer;
use Tempest\Highlight\CommonMark\HighlightExtension;
use Spatie\CommonMarkHighlighter\FencedCodeRenderer;
use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
#[Table('notes')]
#[Fillable(['note', 'in_reply_to', 'client_id'])]
#[Hidden(['searchable'])]
#[ObservedBy(NoteObserver::class)]
class Note extends Model
{
use HasFactory;
@ -65,6 +62,16 @@ class Note extends Model
/** @var string */
protected $table = 'notes';
/** @var array<int, string> */
protected $fillable = [
'note',
'in_reply_to',
'client_id',
];
/** @var array<int, string> */
protected $hidden = ['searchable'];
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
@ -85,12 +92,9 @@ class Note extends Model
return $this->belongsTo(Place::class);
}
public function media(): BelongsToMany
public function media(): HasMany
{
return $this->BelongsToMany(Media::class)
->withPivot('alt_text', 'order')
->withTimestamps()
->orderBy('order');
return $this->hasMany(Media::class);
}
/**
@ -120,7 +124,7 @@ class Note extends Model
public function getNoteAttribute(?string $value): ?string
{
if ($value === null && $this->place !== null) {
$value = '📍: <a href="'.$this->place->uri.'">'.$this->place->name.'</a>';
$value = '📍: <a href="' . $this->place->uri . '">' . $this->place->name . '</a>';
}
// if $value is still null, just return null
@ -144,13 +148,13 @@ class Note extends Model
foreach ($this->media as $media) {
if ($media->type === 'image') {
$note .= PHP_EOL.'<img src="'.$media->url.'" alt="">';
$note .= PHP_EOL . '<img src="' . $media->url . '" alt="">';
}
if ($media->type === 'audio') {
$note .= PHP_EOL.'<audio src="'.$media->url.'">';
$note .= PHP_EOL . '<audio src="' . $media->url . '">';
}
if ($media->type === 'video') {
$note .= PHP_EOL.'<video src="'.$media->url.'">';
$note .= PHP_EOL . '<video src="' . $media->url . '">';
}
}
@ -170,7 +174,7 @@ class Note extends Model
public function getUriAttribute(): string
{
return config('app.url').'/notes/'.$this->nb60id;
return config('app.url') . '/notes/' . $this->nb60id;
}
public function getIso8601Attribute(): string
@ -235,6 +239,43 @@ class Note extends Model
return null;
}
/**
* Get the OEmbed html for a tweet the note is a reply to.
*/
public function getTwitterAttribute(): ?object
{
if (
$this->in_reply_to === null ||
! $this->isTwitterLink($this->in_reply_to)
) {
return null;
}
$tweetId = basename($this->in_reply_to);
if (Cache::has($tweetId)) {
return Cache::get($tweetId);
}
try {
$codebird = resolve(Codebird::class);
$oEmbed = $codebird->statuses_oembed([
'url' => $this->in_reply_to,
'dnt' => true,
'align' => 'center',
'maxwidth' => 512,
]);
if ($oEmbed->httpstatus >= 400) {
throw new Exception;
}
} catch (Exception $e) {
return null;
}
Cache::put($tweetId, $oEmbed, ($oEmbed->cache_age));
return $oEmbed;
}
/**
* Scope a query to select a note via a NewBase60 id.
*/
@ -270,14 +311,14 @@ class Note extends Model
self::USERNAMES_REGEX,
function ($matches) {
if (is_null($this->contacts[$matches[1]])) {
return '<a href="https://twitter.com/'.$matches[1].'">'.$matches[0].'</a>';
return '<a href="https://twitter.com/' . $matches[1] . '">' . $matches[0] . '</a>';
}
$contact = $this->contacts[$matches[1]]; // easier to read the following code
$host = parse_url($contact->homepage, PHP_URL_HOST);
$contact->photo = '/assets/profile-images/default-image';
if (file_exists(public_path().'/assets/profile-images/'.$host.'/image')) {
$contact->photo = '/assets/profile-images/'.$host.'/image';
if (file_exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
$contact->photo = '/assets/profile-images/' . $host . '/image';
}
return trim(view('templates.mini-hcard', ['contact' => $contact])->render());
@ -328,8 +369,8 @@ class Note extends Model
'/#([^\s[:punct:]]+)/',
function ($matches) {
return '<a rel="tag" class="p-category" href="/notes/tagged/'
.Tag::normalize($matches[1]).'">#'
.$matches[1].'</a>';
. Tag::normalize($matches[1]) . '">#'
. $matches[1] . '</a>';
},
$note
);
@ -351,8 +392,9 @@ class Note extends Model
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new AutolinkExtension);
$environment->addExtension(new MentionExtension);
$environment->addExtension(new HighlightExtension);
$environment->addRenderer(Mention::class, new MentionRenderer);
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer);
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer);
$markdownConverter = new MarkdownConverter($environment);
return $markdownConverter->convert($note)->getContent();
@ -360,56 +402,64 @@ class Note extends Model
public function reverseGeoCode(float $latitude, float $longitude): string
{
$latLng = $latitude.','.$longitude;
$latLng = $latitude . ',' . $longitude;
return Cache::get($latLng, function () use ($latLng, $latitude, $longitude) {
$response = Http::withHeaders(['User-Agent' => 'jonnybarnes.uk, email jonny@jonnybarnes.uk'])
->get('https://nominatim.openstreetmap.org/reverse', [
$guzzle = resolve(Client::class);
$response = $guzzle->request('GET', 'https://nominatim.openstreetmap.org/reverse', [
'query' => [
'format' => 'json',
'lat' => $latitude,
'lon' => $longitude,
'zoom' => 18,
'addressdetails' => 1,
]);
$json = $response->object();
],
'headers' => ['User-Agent' => 'jonnybarnes.uk via Guzzle, email jonny@jonnybarnes.uk'],
]);
$json = json_decode((string) $response->getBody());
if (isset($json->address->suburb)) {
$locality = $json->address->suburb;
if (isset($json->address->city)) {
$locality .= ', '.$json->address->city;
$locality .= ', ' . $json->address->city;
}
$address = '<span class="p-locality">'
.$locality
.'</span>, <span class="p-country-name">'
.$json->address->country
.'</span>';
. $locality
. '</span>, <span class="p-country-name">'
. $json->address->country
. '</span>';
Cache::forever($latLng, $address);
return $address;
}
if (isset($json->address->city)) {
$address = '<span class="p-locality">'
.$json->address->city
.'</span>, <span class="p-country-name">'
.$json->address->country
.'</span>';
. $json->address->city
. '</span>, <span class="p-country-name">'
. $json->address->country
. '</span>';
Cache::forever($latLng, $address);
return $address;
}
if (isset($json->address->county)) {
$address = '<span class="p-region">'
.$json->address->county
.'</span>, <span class="p-country-name">'
.$json->address->country
.'</span>';
. $json->address->county
. '</span>, <span class="p-country-name">'
. $json->address->country
. '</span>';
Cache::forever($latLng, $address);
return $address;
}
$address = '<span class="p-country-name">'.$json->address->country.'</span>';
$address = '<span class="p-country-name">' . $json->address->country . '</span>';
Cache::forever($latLng, $address);
return $address;
});
}
private function isTwitterLink(string $inReplyTo): bool
{
return str_starts_with($inReplyTo, 'https://twitter.com/');
}
}

View file

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

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Models;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'slug'])]
class Place extends Model
{
use HasFactory;
@ -24,6 +22,9 @@ class Place extends Model
return 'slug';
}
/** @var array<int, string> */
protected $fillable = ['name', 'slug'];
/** @var array<string, string> */
protected $casts = [
'latitude' => 'float',
@ -76,7 +77,7 @@ class Place extends Model
protected function uri(): Attribute
{
return Attribute::get(
get: static fn ($value, $attributes) => config('app.url').'/places/'.$attributes['slug'],
get: static fn ($value, $attributes) => config('app.url') . '/places/' . $attributes['slug'],
);
}

View file

@ -4,20 +4,40 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['uid', 'name', 'service_name', 'service_url', 'service_photo', 'user_name', 'user_url', 'user_photo'])]
#[Visible(['uid', 'name', 'service', 'user'])]
#[Appends(['service', 'user'])]
class SyndicationTarget extends Model
{
use HasFactory;
/** @var array<int, string> */
protected $fillable = [
'uid',
'name',
'service_name',
'service_url',
'service_photo',
'user_name',
'user_url',
'user_photo',
];
/** @var array<int, string> */
protected $visible = [
'uid',
'name',
'service',
'user',
];
/** @var array<int, string> */
protected $appends = [
'service',
'user',
];
protected function service(): Attribute
{
return Attribute::get(

View file

@ -4,18 +4,19 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
#[Guarded(['id'])]
class Tag extends Model
{
use HasFactory;
/** @var array<int, string> */
protected $guarded = ['id'];
public function notes(): BelongsToMany
{
return $this->belongsToMany(Note::class);

View file

@ -4,34 +4,27 @@ declare(strict_types=1);
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasFactory;
use Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/** @var array<int, string> */
protected $fillable = [
'name', 'password',
];
/** @var array<int, string> */
protected $hidden = [
'current_password',
'password',
'remember_token',
];
public function passkey(): HasMany
{

View file

@ -5,23 +5,27 @@ declare(strict_types=1);
namespace App\Models;
use App\Traits\FilterHtml;
use Codebird\Codebird;
use Exception;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Jonnybarnes\WebmentionsParser\Authorship;
#[Table('webmentions')]
#[Guarded(['id'])]
class WebMention extends Model
{
use FilterHtml;
use HasFactory;
/** @var string */
protected $table = 'webmentions';
/** @var array<int, string> */
protected $guarded = ['id'];
public function commentable(): MorphTo
{
return $this->morphTo();
@ -123,9 +127,22 @@ class WebMention extends Model
return str_replace('http://', 'https://', $url);
}
if ($host === 'twitter.com') {
if (Cache::has($url)) {
return Cache::get($url);
}
$username = ltrim(parse_url($url, PHP_URL_PATH), '/');
$codebird = resolve(Codebird::class);
$info = $codebird->users_show(['screen_name' => $username]);
$profile_image = $info->profile_image_url_https;
Cache::put($url, $profile_image, 10080); // 1 week
return $profile_image;
}
$filesystem = new Filesystem;
if ($filesystem->exists(public_path().'/assets/profile-images/'.$host.'/image')) {
return '/assets/profile-images/'.$host.'/image';
if ($filesystem->exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
return '/assets/profile-images/' . $host . '/image';
}
return $url;

View file

@ -2,10 +2,14 @@
namespace App\Providers;
use App\Models\Note;
use App\Observers\NoteObserver;
use Codebird\Codebird;
use GuzzleHttp\Client;
use GuzzleHttp\Middleware;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
@ -29,6 +33,33 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Note::observe(NoteObserver::class);
// configure Intervention/Image
$this->app->bind('Intervention\Image\ImageManager', function () {
return \Intervention\Image\ImageManager::withDriver(config('image.driver'));
});
// Bind the Codebird client
// Codebird gets mocked in tests
// @codeCoverageIgnoreStart
$this->app->bind('Codebird\Codebird', function () {
Codebird::setConsumerKey(
env('TWITTER_CONSUMER_KEY'),
env('TWITTER_CONSUMER_SECRET')
);
$cb = Codebird::getInstance();
$cb->setToken(
env('TWITTER_ACCESS_TOKEN'),
env('TWITTER_ACCESS_TOKEN_SECRET')
);
return $cb;
});
// @codeCoverageIgnoreEnd
/**
* Paginate a standard Laravel Collection.
*
@ -73,10 +104,39 @@ class AppServiceProvider extends ServiceProvider
);
});
// Configure Guzzle
$this->app->bind('RetryGuzzle', function () {
$handlerStack = \GuzzleHttp\HandlerStack::create();
$handlerStack->push(Middleware::retry(
function ($retries, $request, $response, $exception) {
// Limit the number of retries to 5
if ($retries >= 5) {
return false;
}
// Retry connection exceptions
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
return true;
}
// Retry on server errors
if ($response && $response->getStatusCode() >= 500) {
return true;
}
// Finally for CloudConvert, retry if status is not final
return json_decode($response, false, 512, JSON_THROW_ON_ERROR)->data->status !== 'finished';
},
function () {
// Retry after 1 second
return 1000;
}
));
return new Client(['handler' => $handlerStack]);
});
// Turn on Eloquent strict mode when developing
Model::shouldBeStrict(! $this->app->isProduction());
// Force HTTPS URL generation in production
URL::forceHttps($this->app->isProduction());
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace App\Providers;
use App\Services\Micropub\Handlers\CardHandler;
use App\Services\Micropub\Handlers\EntryHandler;
use App\Services\Micropub\Handlers\UpdateHandler;
use App\Services\Micropub\CardHandler;
use App\Services\Micropub\EntryHandler;
use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Support\ServiceProvider;
@ -20,7 +19,6 @@ class MicropubServiceProvider extends ServiceProvider
// Register handlers
$registry->register('card', new CardHandler);
$registry->register('entry', new EntryHandler);
$registry->register('update', new UpdateHandler);
return $registry;
});

View file

@ -8,8 +8,10 @@ use App\Exceptions\InternetArchiveException;
use App\Jobs\ProcessBookmark;
use App\Models\Bookmark;
use App\Models\Tag;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class BookmarkService
@ -53,19 +55,21 @@ class BookmarkService
* Given a URL, attempt to save it to the Internet Archive.
*
* @throws InternetArchiveException
* @throws GuzzleException
*/
public function getArchiveLink(string $url): string
{
$response = Http::get('https://web.archive.org/save/'.$url);
if ($response->clientError()) {
$client = resolve(Client::class);
try {
$response = $client->request('GET', 'https://web.archive.org/save/' . $url);
} catch (ClientException $e) {
// throw an exception to be caught
throw new InternetArchiveException;
}
$contentLocation = $response->header('Content-Location');
if ($contentLocation !== '' && Str::startsWith($contentLocation, '/web')) {
return $contentLocation;
if ($response->hasHeader('Content-Location')) {
if (Str::startsWith(Arr::get($response->getHeader('Content-Location'), 0), '/web')) {
return $response->getHeader('Content-Location')[0];
}
}
// throw an exception to be caught

View file

@ -2,28 +2,20 @@
declare(strict_types=1);
namespace App\Services\Micropub\Handlers;
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Services\Micropub\Data\CardData;
use App\Services\Micropub\Data\MicropubData;
use App\Services\PlaceService;
class CardHandler implements MicropubHandlerInterface
{
public function dataClass(): string
{
return CardData::class;
}
/**
* @throws InvalidTokenScopeException
*/
public function handle(MicropubData $data): array
public function handle(array $data): array
{
assert($data instanceof CardData);
$scopes = $data->tokenData['scope'];
// Handle h-card requests
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
@ -32,7 +24,7 @@ class CardHandler implements MicropubHandlerInterface
throw new InvalidTokenScopeException;
}
$location = resolve(PlaceService::class)->createPlace($data->toArray())->uri;
$location = resolve(PlaceService::class)->createPlace($data)->uri;
return [
'response' => 'created',

View file

@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class CardData extends MicropubData
{
public function __construct(
public readonly array $tokenData,
public readonly ?string $name,
public readonly ?string $description,
public readonly mixed $geo,
public readonly mixed $location,
public readonly ?string $latitude,
public readonly ?string $longitude,
) {}
public static function fromRequest(Request $request): static
{
if ($request->isJson()) {
$data = $request->json()->all();
return new static(
tokenData: $data['token_data'],
name: Arr::get($data, 'properties.name.0'),
description: Arr::get($data, 'properties.description.0'),
geo: Arr::get($data, 'properties.geo.0'),
location: Arr::get($data, 'properties.location'),
latitude: null,
longitude: null,
);
}
return new static(
tokenData: $request->input('token_data'),
name: $request->input('name'),
description: $request->input('description'),
geo: $request->input('geo'),
location: $request->input('location'),
latitude: $request->input('latitude'),
longitude: $request->input('longitude'),
);
}
public static function fromArray(array $data): static
{
return new static(
tokenData: $data['token_data'],
name: $data['name'] ?? null,
description: $data['description'] ?? null,
geo: $data['geo'] ?? null,
location: $data['location'] ?? null,
latitude: $data['latitude'] ?? null,
longitude: $data['longitude'] ?? null,
);
}
public function toArray(): array
{
return [
'token_data' => $this->tokenData,
'name' => $this->name,
'description' => $this->description,
'geo' => $this->geo,
'location' => $this->location,
'latitude' => $this->latitude,
'longitude' => $this->longitude,
];
}
}

View file

@ -1,125 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class EntryData extends MicropubData
{
public function __construct(
public readonly array $tokenData,
public readonly ?string $content,
public readonly ?string $inReplyTo,
public readonly ?string $published,
public readonly mixed $location,
public readonly ?string $bookmarkOf,
public readonly ?string $likeOf,
public readonly ?array $mpSyndicateTo,
public readonly ?string $name,
public readonly ?string $description,
public readonly mixed $geo,
public readonly mixed $checkin,
public readonly mixed $syndication,
public readonly ?array $photos,
) {}
public static function fromRequest(Request $request): static
{
if ($request->isJson()) {
$data = $request->json()->all();
$rawContent = Arr::get($data, 'properties.content.0');
$content = is_array($rawContent) ? ($rawContent['html'] ?? $rawContent['value'] ?? null) : $rawContent;
return new static(
tokenData: $data['token_data'],
content: $content,
inReplyTo: Arr::get($data, 'properties.in-reply-to.0'),
published: Arr::get($data, 'properties.published.0'),
location: self::extractLocationData($data),
bookmarkOf: Arr::get($data, 'properties.bookmark-of.0'),
likeOf: Arr::get($data, 'properties.like-of.0'),
mpSyndicateTo: Arr::get($data, 'properties.mp-syndicate-to'),
name: Arr::get($data, 'properties.name.0'),
description: Arr::get($data, 'properties.description.0'),
geo: Arr::get($data, 'properties.geo.0'),
checkin: Arr::get($data, 'properties.checkin.0'),
syndication: Arr::get($data, 'properties.syndication.0'),
photos: Arr::get($data, 'properties.photo'),
);
}
return new static(
tokenData: $request->input('token_data'),
content: $request->input('content'),
inReplyTo: $request->input('in-reply-to'),
published: $request->input('published'),
location: $request->input('location'),
bookmarkOf: $request->input('bookmark-of'),
likeOf: $request->input('like-of'),
mpSyndicateTo: $request->input('mp-syndicate-to'),
name: $request->input('name'),
description: $request->input('description'),
geo: $request->input('geo'),
checkin: $request->input('checkin'),
syndication: $request->input('syndication'),
photos: $request->input('photos'),
);
}
private static function extractLocationData(array $data): array|string|null
{
if (! Arr::has($data, 'properties.location')) {
return null;
}
if (Arr::has($data, 'properties.location.0')) {
return Arr::get($data, 'properties.location.0');
}
return Arr::get($data, 'properties.location');
}
public static function fromArray(array $data): static
{
return new static(
tokenData: $data['token_data'],
content: $data['content'] ?? null,
inReplyTo: $data['in-reply-to'] ?? null,
published: $data['published'] ?? null,
location: $data['location'] ?? null,
bookmarkOf: $data['bookmark-of'] ?? null,
likeOf: $data['like-of'] ?? null,
mpSyndicateTo: $data['mp-syndicate-to'] ?? null,
name: $data['name'] ?? null,
description: $data['description'] ?? null,
geo: $data['geo'] ?? null,
checkin: $data['checkin'] ?? null,
syndication: $data['syndication'] ?? null,
photos: $data['photos'] ?? null,
);
}
public function toArray(): array
{
return [
'token_data' => $this->tokenData,
'content' => $this->content,
'in-reply-to' => $this->inReplyTo,
'published' => $this->published,
'location' => $this->location,
'bookmark-of' => $this->bookmarkOf,
'like-of' => $this->likeOf,
'mp-syndicate-to' => $this->mpSyndicateTo,
'name' => $this->name,
'description' => $this->description,
'geo' => $this->geo,
'checkin' => $this->checkin,
'syndication' => $this->syndication,
'photos' => $this->photos,
];
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
abstract class MicropubData
{
abstract public static function fromRequest(Request $request): static;
abstract public static function fromArray(array $data): static;
abstract public function toArray(): array;
}

View file

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
class UpdateData extends MicropubData
{
public function __construct(
public readonly array $tokenData,
public readonly ?string $updateUrl,
public readonly ?array $updateReplace,
public readonly ?array $updateAdd,
public readonly ?array $updateDelete,
) {}
public static function fromRequest(Request $request): static
{
if ($request->isJson()) {
$data = $request->json()->all();
return new static(
tokenData: $data['token_data'],
updateUrl: $data['url'] ?? null,
updateReplace: $data['replace'] ?? null,
updateAdd: $data['add'] ?? null,
updateDelete: $data['delete'] ?? null,
);
}
return new static(
tokenData: $request->input('token_data'),
updateUrl: $request->input('url'),
updateReplace: $request->input('replace'),
updateAdd: $request->input('add'),
updateDelete: $request->input('delete'),
);
}
public static function fromArray(array $data): static
{
return new static(
tokenData: $data['token_data'],
updateUrl: $data['update_url'] ?? null,
updateReplace: $data['update_replace'] ?? null,
updateAdd: $data['update_add'] ?? null,
updateDelete: $data['update_delete'] ?? null,
);
}
public function toArray(): array
{
return [
'token_data' => $this->tokenData,
'update_url' => $this->updateUrl,
'update_replace' => $this->updateReplace,
'update_add' => $this->updateAdd,
'update_delete' => $this->updateDelete,
];
}
}

View file

@ -2,31 +2,22 @@
declare(strict_types=1);
namespace App\Services\Micropub\Handlers;
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Services\ArticleService;
use App\Services\BookmarkService;
use App\Services\LikeService;
use App\Services\Micropub\Data\EntryData;
use App\Services\Micropub\Data\MicropubData;
use App\Services\NoteService;
class EntryHandler implements MicropubHandlerInterface
{
public function dataClass(): string
{
return EntryData::class;
}
/**
* @throws InvalidTokenScopeException
*/
public function handle(MicropubData $data): array
public function handle(array $data)
{
assert($data instanceof EntryData);
$scopes = $data->tokenData['scope'];
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
@ -35,12 +26,11 @@ class EntryHandler implements MicropubHandlerInterface
throw new InvalidTokenScopeException;
}
$dataArray = $data->toArray();
$location = match (true) {
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link,
default => resolve(NoteService::class)->create($dataArray)->uri,
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 [

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Handlers;
use App\Services\Micropub\Data\MicropubData;
/**
* Contract for all Micropub request handlers.
*
* The Micropub endpoint receives different types of request creating an
* h-entry (note, article, bookmark, like), creating an h-card (place), or
* updating an existing post. Each type is handled by its own class that
* implements this interface.
*
* Each handler declares the typed data object it needs via dataClass(). The
* controller calls dataClass()::fromArray() to build the right object before
* passing it to handle(), so the handler always receives a concrete typed
* object rather than a raw array.
*
* Handlers live in App\Services\Micropub\Handlers.
* Their corresponding data objects live in App\Services\Micropub\Data.
* Handlers are registered in MicropubServiceProvider and looked up by type
* string (e.g. "entry", "card", "update") via MicropubHandlerRegistry.
*/
interface MicropubHandlerInterface
{
/**
* Return the fully-qualified class name of the data object this handler
* expects. The controller will call fromArray() on this class to build the
* object from the normalised request data before calling handle().
*
* @return class-string<MicropubData>
*/
public function dataClass(): string;
/**
* Process the request and return a result array with at minimum a
* 'response' key ('created' or 'updated') and a 'url' key pointing to
* the affected resource.
*/
public function handle(MicropubData $data): array;
}

View file

@ -1,150 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Handlers;
use App\Exceptions\InvalidTokenScopeException;
use App\Exceptions\MicropubHandlerException;
use App\Exceptions\MicropubUnsupportedModelException;
use App\Models\Media;
use App\Models\Note;
use App\Services\Micropub\Data\MicropubData;
use App\Services\Micropub\Data\UpdateData;
use Illuminate\Support\Str;
class UpdateHandler implements MicropubHandlerInterface
{
public function dataClass(): string
{
return UpdateData::class;
}
/**
* @throws InvalidTokenScopeException
* @throws MicropubUnsupportedModelException
* @throws MicropubHandlerException
*/
public function handle(MicropubData $data): array
{
assert($data instanceof UpdateData);
$scopes = $data->tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$urlPath = parse_url($data->updateUrl, PHP_URL_PATH);
if (mb_substr($urlPath, 1, 5) !== 'notes') {
throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes');
}
$note = Note::nb60(basename($urlPath))->firstOrFail();
if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) {
throw new MicropubHandlerException('Unsupported update operation');
}
if ($data->updateReplace !== null) {
foreach ($data->updateReplace as $property => $value) {
match ($property) {
'content' => $note->note = is_array($value[0]) ? ($value[0]['html'] ?? $value[0]['value'] ?? null) : $value[0],
'syndication' => $this->applySyndication($note, $value),
default => null,
};
}
}
if ($data->updateAdd !== null) {
foreach ($data->updateAdd as $property => $value) {
if ($property === 'syndication') {
$this->applySyndication($note, $value);
}
if ($property === 'photo') {
foreach ($value as $photoURL) {
if (Str::startsWith($photoURL, 'https://')) {
$media = new Media;
$media->path = $photoURL;
$media->type = 'image';
$media->save();
$note->media()->save($media);
}
}
}
}
}
if ($data->updateDelete !== null) {
$this->applyDelete($note, $data->updateDelete);
}
$note->save();
return [
'response' => 'updated',
'url' => $note->uri,
];
}
private function applyDelete(Note $note, array $delete): void
{
if (array_is_list($delete)) {
foreach ($delete as $property) {
match ($property) {
'syndication' => $this->clearSyndication($note),
'photo' => $note->media()->delete(),
default => null,
};
}
return;
}
foreach ($delete as $property => $values) {
if ($property === 'syndication') {
$this->removeSyndicationValues($note, $values);
}
if ($property === 'photo') {
$note->media()->whereIn('path', $values)->delete();
}
}
}
private function clearSyndication(Note $note): void
{
$note->facebook_url = null;
$note->swarm_url = null;
$note->tweet_id = null;
}
private function removeSyndicationValues(Note $note, array $urls): void
{
foreach ($urls as $url) {
if (Str::startsWith($url, 'https://www.facebook.com') && $note->facebook_url === $url) {
$note->facebook_url = null;
} elseif (Str::startsWith($url, 'https://www.swarmapp.com') && $note->swarm_url === $url) {
$note->swarm_url = null;
} elseif (Str::startsWith($url, 'https://twitter.com')) {
$note->tweet_id = null;
}
}
}
private function applySyndication(Note $note, array $urls): void
{
foreach ($urls as $url) {
if (Str::startsWith($url, 'https://www.facebook.com')) {
$note->facebook_url = $url;
} elseif (Str::startsWith($url, 'https://www.swarmapp.com')) {
$note->swarm_url = $url;
} elseif (Str::startsWith($url, 'https://twitter.com')) {
$note->tweet_id = basename(parse_url($url, PHP_URL_PATH));
}
}
}
}

View file

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

View file

@ -5,27 +5,7 @@ declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\MicropubHandlerException;
use App\Services\Micropub\Handlers\MicropubHandlerInterface;
/**
* Maps Micropub post types to their handler instances.
*
* MicropubRequest normalises every incoming request and resolves it to a type
* string ("entry", "card", "update"). The controller asks the registry for the
* right handler, then asks the handler which data class to build, and finally
* calls handle() with that data object.
*
* Flow:
* MicropubRequest (normalise) MicropubController
* MicropubHandlerRegistry::getHandler($type)
* $handler->dataClass()::fromArray($rawData)
* $handler->handle($dataObject)
*
* Handlers are registered in MicropubServiceProvider. To support a new
* Micropub post type, create a handler in App\Services\Micropub\Handlers, a
* matching data class in App\Services\Micropub\Data, and register the handler
* here with its type string.
*/
class MicropubHandlerRegistry
{
/**
@ -33,9 +13,6 @@ class MicropubHandlerRegistry
*/
protected array $handlers = [];
/**
* Register a handler for a given Micropub type string.
*/
public function register(string $type, MicropubHandlerInterface $handler): self
{
$this->handlers[$type] = $handler;
@ -44,8 +21,6 @@ class MicropubHandlerRegistry
}
/**
* Retrieve the handler for a given type, or throw if none is registered.
*
* @throws MicropubHandlerException
*/
public function getHandler(string $type): MicropubHandlerInterface

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Models\Media;
use App\Models\Note;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
/*
* @todo Implement this properly
*/
class UpdateHandler implements MicropubHandlerInterface
{
/**
* @throws InvalidTokenScopeException
*/
public function handle(array $data)
{
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$urlPath = parse_url(Arr::get($data, 'url'), PHP_URL_PATH);
// is it a note we are updating?
if (mb_substr($urlPath, 1, 5) !== 'notes') {
return response()->json([
'error' => 'invalid',
'error_description' => 'This implementation currently only support the updating of notes',
], 500);
}
try {
$note = Note::nb60(basename($urlPath))->firstOrFail();
} catch (ModelNotFoundException) {
return response()->json([
'error' => 'invalid_request',
'error_description' => 'No known note with given ID',
], 404);
}
// got the note, are we dealing with a “replace” request?
if (Arr::get($data, 'replace')) {
foreach (Arr::get($data, 'replace') as $property => $value) {
if ($property === 'content') {
$note->note = $value[0];
}
if ($property === 'syndication') {
foreach ($value as $syndicationURL) {
if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) {
$note->facebook_url = $syndicationURL;
}
if (Str::startsWith($syndicationURL, 'https://www.swarmapp.com')) {
$note->swarm_url = $syndicationURL;
}
if (Str::startsWith($syndicationURL, 'https://twitter.com')) {
$note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH));
}
}
}
}
$note->save();
return [
'response' => 'updated',
];
}
// how about “add”
if (Arr::get($data, 'add')) {
foreach (Arr::get($data, 'add') as $property => $value) {
if ($property === 'syndication') {
foreach ($value as $syndicationURL) {
if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) {
$note->facebook_url = $syndicationURL;
}
if (Str::startsWith($syndicationURL, 'https://www.swarmapp.com')) {
$note->swarm_url = $syndicationURL;
}
if (Str::startsWith($syndicationURL, 'https://twitter.com')) {
$note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH));
}
}
}
if ($property === 'photo') {
foreach ($value as $photoURL) {
if (Str::startsWith($photoURL, 'https://')) {
$media = new Media;
$media->path = $photoURL;
$media->type = 'image';
$media->save();
$note->media()->save($media);
}
}
}
}
$note->save();
return response()->json([
'response' => 'updated',
]);
}
return response()->json([
'response' => 'error',
'error_description' => 'unsupported request',
], 500);
}
}

View file

@ -48,15 +48,12 @@ class NoteService
$note->place()->associate($this->getCheckin($data));
$note->swarm_url = $this->getSwarmUrl($data);
}
// $note->instagram_url = $this->getInstagramUrl($request);
foreach ($this->getMedia($data) as $index => $media) {
$note->media()->attach($media['value']->id, [
'alt_text' => $media['alt'],
'order' => $index,
]);
}
//
// $note->instagram_url = $this->getInstagramUrl($request);
//
// foreach ($this->getMedia($request) as $media) {
// $note->media()->save($media);
// }
$note->save();
@ -93,7 +90,7 @@ class NoteService
$matches
);
return $matches[0][0].', '.$matches[0][1];
return $matches[0][0] . ', ' . $matches[0][1];
}
return null;
@ -191,44 +188,23 @@ class NoteService
/**
* Get the media URLs from the request to create a new note.
*/
private function getMedia(array $data): array
private function getMedia(array $request): array
{
$media = [];
$photos = Arr::get($data, 'photos');
$photos = Arr::get($request, 'photo') ?? Arr::get($request, 'properties.photo');
if (isset($photos)) {
foreach ((array) $photos as $photo) {
// $photo can be a string of the URL opf the photo
// or it can be an object with a `value` and `alt`
$photoUrl = null;
$photoAlt = null;
if (is_string($photo)) {
$photoUrl = $photo;
} elseif (is_array($photo)) {
$photoUrl = $photo['value'];
$photoAlt = $photo['alt'];
}
if (empty($photoUrl)) {
continue;
}
// check the media was uploaded to my endpoint, and use path
if (Str::startsWith($photoUrl, config('filesystems.disks.public.url'))) {
$path = substr($photoUrl, strlen(config('filesystems.disks.public.url')));
$media[] = [
'value' => Media::where('path', ltrim($path, '/'))->firstOrFail(),
'alt' => $photoAlt,
];
if (Str::startsWith($photo, config('filesystems.disks.s3.url'))) {
$path = substr($photo, strlen(config('filesystems.disks.s3.url')));
$media[] = Media::where('path', ltrim($path, '/'))->firstOrFail();
} else {
$newMedia = Media::firstOrNew(['path' => $photoUrl]);
$newMedia = Media::firstOrNew(['path' => $photo]);
// currently assuming this is a photo from Swarm or OwnYourGram
$newMedia->type = 'image';
$newMedia->save();
$media[] = [
'value' => $newMedia,
'alt' => $photoAlt,
];
$media[] = $newMedia;
}
}
}

View file

@ -15,17 +15,14 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware
->append(LinkHeadersMiddleware::class)
->preventRequestForgery(
except: [
'auth', // This is the IndieAuth auth endpoint
'token', // This is the IndieAuth token endpoint
'api/post',
'api/media',
'micropub/places',
'webmention',
],
originOnly: true
);
->validateCsrfTokens(except: [
'auth', // This is the IndieAuth auth endpoint
'token', // This is the IndieAuth token endpoint
'api/post',
'api/media',
'micropub/places',
'webmention',
]);
})
->withExceptions(function (Exceptions $exceptions) {
Flare::handles($exceptions);

View file

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

View file

@ -6,43 +6,49 @@
"keywords": ["laravel", "framework", "indieweb"],
"license": "CC0-1.0",
"require": {
"php": "^8.3",
"php": "^8.2",
"ext-dom": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pgsql": "*",
"ext-sodium": "*",
"cviebrock/eloquent-sluggable": "^13.0",
"cviebrock/eloquent-sluggable": "^12.0",
"guzzlehttp/guzzle": "^7.2",
"indieauth/client": "^1.1",
"intervention/image": "^4.0",
"intervention/image": "^3",
"jonnybarnes/indieweb": "~0.2",
"jonnybarnes/webmentions-parser": "~0.5",
"laravel/framework": "^13.0",
"jublonet/codebird-php": "4.0.0-beta.1",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.0",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.1",
"laravel/tinker": "^3.0",
"laravel/tinker": "^2.8",
"lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3",
"spatie/laravel-flare": "^3.0",
"symfony/html-sanitizer": "^8.0",
"tempest/highlight": "^2.27",
"phpdocumentor/reflection-docblock": "^5.3",
"spatie/commonmark-highlighter": "^3.0",
"spatie/laravel-flare": "^2.2",
"symfony/html-sanitizer": "^7.0",
"symfony/property-access": "^7.0",
"symfony/serializer": "^7.0",
"web-auth/webauthn-lib": "^5.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^4.0.9",
"barryvdh/laravel-debugbar": "^3.0",
"barryvdh/laravel-ide-helper": "^3.0",
"fakerphp/faker": "^1.23",
"fakerphp/faker": "^1.9.2",
"laravel/dusk": "^8.0",
"laravel/pail": "^1.2.5",
"laravel/pint": "^1.27",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/php-code-coverage": "^14.0",
"phpunit/phpunit": "^13.0",
"laravel/pail": "^1.2",
"laravel/pint": "^1.0",
"laravel/sail": "^1.18",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1",
"openai-php/client": "^0.17.1",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpunit": "^11.0",
"spatie/laravel-ray": "^1.12",
"spatie/x-ray": "^1.2"
},
@ -62,22 +68,6 @@
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
@ -93,8 +83,9 @@
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
"dev": [
"Composer\\Config::disableProcessTimeout",
"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": {
@ -108,7 +99,8 @@
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
"php-http/discovery": true,
"composer/installers": true
}
},
"minimum-stability": "stable",

4471
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -101,7 +101,7 @@ return [
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
explode(',', env('APP_PREVIOUS_KEYS', ''))
),
],

View file

@ -1,7 +1,5 @@
<?php
use App\Models\User;
return [
/*
@ -64,7 +62,7 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
@ -106,7 +104,7 @@ return [
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| Here you may define the amount of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|

31
config/bridgy.php Normal file
View file

@ -0,0 +1,31 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Mastodon Token
|--------------------------------------------------------------------------
|
| When syndicating posts to Mastodon using Brid.gys Micropub endpoint, we
| need to provide an access token. This token can be generated by going to
| https://brid.gy/mastodon and clicking the “Get token” button.
|
*/
'mastodon_token' => env('BRIDGY_MASTODON_TOKEN'),
/*
|--------------------------------------------------------------------------
| Bluesky Token
|--------------------------------------------------------------------------
|
| When syndicating posts to Bluesky using Brid.gys Micropub endpoint, we
| need to provide an access token. This token can be generated by going to
| https://brid.gy/bluesky and clicking the “Get token” button.
|
*/
'bluesky_token' => env('BRIDGY_BLUESKY_TOKEN'),
];

View file

@ -26,9 +26,8 @@ return [
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
| Supported drivers: "apc", "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", "null"
|
*/
@ -41,10 +40,9 @@ return [
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'connection' => env('DB_CACHE_CONNECTION'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
@ -91,14 +89,6 @@ return [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
@ -112,19 +102,6 @@ return [
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
];

View file

@ -1,7 +1,6 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
@ -41,7 +40,6 @@ return [
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
@ -60,7 +58,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@ -80,7 +78,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@ -96,7 +94,7 @@ return [
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
'sslmode' => 'prefer',
],
'sqlsrv' => [
@ -149,7 +147,7 @@ return [
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'persistent' => env('REDIS_PERSISTENT', false),
],
@ -160,10 +158,6 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
@ -173,10 +167,6 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],

View file

@ -29,7 +29,7 @@ return [
'storage' => [
'enabled' => true,
'driver' => 'file', // redis, file, pdo
'path' => storage_path().'/debugbar', // For file driver
'path' => storage_path() . '/debugbar', // For file driver
'connection' => null, // Leave null for default connection (Redis/PDO)
],

View file

@ -24,7 +24,7 @@ return [
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
| Supported Drivers: "local", "ftp", "sftp", "s3"
|
*/
@ -41,7 +41,7 @@ return [
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,

View file

@ -1,8 +1,8 @@
<?php
use Spatie\FlareClient\Sampling\RateSampler;
use Spatie\FlareClient\Api;
use Spatie\LaravelFlare\AttributesProviders\LaravelUserAttributesProvider;
use Spatie\LaravelFlare\FlareConfig;
use Spatie\LaravelFlare\Senders\LaravelHttpSender;
return [
/*
@ -19,6 +19,17 @@ return [
'key' => env('FLARE_KEY'),
/*
|
|--------------------------------------------------------------------------
| Flare Base URL
|--------------------------------------------------------------------------
|
| Which server should be used to send the reports/traces to.
|
*/
'base_url' => env('FLARE_BASE_URL', Api::BASE_URL),
/*
|--------------------------------------------------------------------------
| Collects
@ -34,6 +45,22 @@ return [
extra: []
),
/*
|--------------------------------------------------------------------------
| Attribute providers
|--------------------------------------------------------------------------
|
| When sending an error report or trace to Flare attributes can be added to
| the report or trace for common entries. An example of such an entry is
| the currently authenticated user. In an attribute provider you can
| specify which attributes should be sent.
|
*/
'attribute_providers' => [
'user' => LaravelUserAttributesProvider::class,
],
/*
|--------------------------------------------------------------------------
| Censor data
@ -59,49 +86,19 @@ return [
'X-XSRF-TOKEN',
],
'client_ips' => false,
'cookies' => false,
'session' => false,
],
/*
|--------------------------------------------------------------------------
| Sender
| Reporting log statements
|--------------------------------------------------------------------------
|
| The sender is responsible for sending the error reports and traces to
| Flare. By default, Laravel Flare sends them over HTTP. To use the local
| Flare daemon, switch the sender class to
| `Spatie\FlareClient\Senders\DaemonSender::class` and set `daemon_url`.
| The daemon sender defaults to localhost on port 8787 and uses its own
| default timeouts and fallback sender config unless you override them.
| If this setting is `false` log statements won't be sent as events to Flare,
| no matter which error level you specified in the Flare log channel.
|
*/
'sender' => [
'class' => LaravelHttpSender::class,
'config' => [
'timeout' => 10,
],
],
// Daemon sender example
// 'sender' => [
// 'class' => \Spatie\FlareClient\Senders\DaemonSender::class,
// 'config' => [
// 'daemon_url' => env('FLARE_DAEMON_URL', 'http://127.0.0.1:8787'),
// ],
// ],
/*
|--------------------------------------------------------------------------
| Report
|--------------------------------------------------------------------------
|
| Flare reports errors and exceptions happening within your application.
|
*/
'report' => env('FLARE_REPORT', true),
'send_logs_as_events' => true,
/*
|--------------------------------------------------------------------------
@ -110,27 +107,10 @@ return [
| When reporting errors, you can specify which error levels should be
| reported. By default, all error levels are reported by setting
| this value to `null`.
*/
*/
'report_error_levels' => null,
/*
|--------------------------------------------------------------------------
| Override grouping
|--------------------------------------------------------------------------
|
| Flare will try to group errors and exceptions as best as possible, that
| being said, sometimes you might want to override the grouping. You can
| do this by adding exception classes to this array which should always
| be grouped by exception class, exception message or exception class
| and message.
|
*/
'overridden_groupings' => [
// Illuminate\Http\Client\ConnectionException::class => Spatie\FlareClient\Enums\OverriddenGrouping::ExceptionMessageAndClass,
],
/*
|--------------------------------------------------------------------------
| Share button
@ -144,6 +124,40 @@ return [
'enable_share_button' => true,
/*
|--------------------------------------------------------------------------
| Override grouping
|--------------------------------------------------------------------------
|
| Flare will try to group errors and exceptions as best as possible, that
| being said, sometimes you might want to override the grouping. You can
| do this by adding exception classes to this array which should always
| be grouped by exception class, exception message or exception class
| and message.
|
*/
'overridden_groupings' => [
// Illuminate\Http\Client\ConnectionException::class => Spatie\FlareClient\Enums\OverriddenGrouping::ExceptionMessageAndClass,
],
/*
|--------------------------------------------------------------------------
| Sender
|--------------------------------------------------------------------------
|
| The sender is responsible for sending the error reports and traces to
| Flare it can be configured if needed.
|
*/
'sender' => [
'class' => \Spatie\LaravelFlare\Senders\LaravelHttpSender::class,
'config' => [
'timeout' => 10,
],
],
/*
|--------------------------------------------------------------------------
| Trace
@ -154,7 +168,7 @@ return [
|
*/
'trace' => env('FLARE_TRACE', true),
'trace' => env('FLARE_TRACE', false),
/*
|--------------------------------------------------------------------------
@ -167,35 +181,26 @@ return [
| which means that 10% of the traces will be recorded.
|
*/
'sampler' => [
'class' => RateSampler::class,
'class' => \Spatie\FlareClient\Sampling\RateSampler::class,
'config' => [
'rate' => env('FLARE_SAMPLER_RATE', 0.1),
'rate' => 0.1,
],
],
/*
|--------------------------------------------------------------------------
| Log
| Trace limits
|--------------------------------------------------------------------------
|
| Logging show you an overview of log entries within your application.
| Limits for the tracing data. These limits are used to prevent
| the tracing data from growing too large.
|
*/
'log' => env('FLARE_LOG', false),
/*
|--------------------------------------------------------------------------
| Minimal log level
|--------------------------------------------------------------------------
|
| You can specify the minimal (Monolog) log level that should be sent to Flare.
| Log levels lower than the specified level will be ignored.
| If null all log levels will be sent to Flare.
|
*/
'minimal_log_level' => null,
'trace_limits' => [
'max_spans' => 512,
'max_attributes_per_span' => 128,
'max_span_events_per_span' => 128,
'max_attributes_per_span_event' => 128,
],
];

View file

@ -99,7 +99,7 @@ return [
'include_helpers' => false,
'helper_files' => [
base_path().'/vendor/laravel/framework/src/Illuminate/Support/helpers.php',
base_path() . '/vendor/laravel/framework/src/Illuminate/Support/helpers.php',
],
/*

22
config/image.php Normal file
View file

@ -0,0 +1,22 @@
<?php
use Intervention\Image\Drivers\Gd\Driver;
return [
/*
|--------------------------------------------------------------------------
| Image Driver
|--------------------------------------------------------------------------
|
| Intervention Image supports "GD Library" and "Imagick" to process images
| internally. You may choose one of them according to your PHP
| configuration. By default PHP's "GD Library" implementation is used.
|
| Supported: "gd", "imagick"
|
*/
'driver' => Driver::class,
];

View file

@ -45,7 +45,7 @@ return [
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| Available Drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
@ -54,7 +54,7 @@ return [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'channels' => explode(',', env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
@ -76,7 +76,7 @@ return [
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
@ -98,10 +98,10 @@ return [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],

View file

@ -30,8 +30,7 @@ return [
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
| "postmark", "log", "array", "failover", "roundrobin"
|
*/
@ -46,7 +45,7 @@ return [
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
@ -61,10 +60,6 @@ return [
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
@ -85,16 +80,6 @@ return [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
@ -112,7 +97,7 @@ return [
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

View file

@ -24,8 +24,7 @@ return [
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
*/
@ -73,22 +72,6 @@ return [
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*

67
config/sanctum.php Normal file
View file

@ -0,0 +1,67 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. If this value is null, personal access tokens do
| not expire. This won't tweak the lifetime of first-party sessions.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
],
];

View file

@ -15,11 +15,7 @@ return [
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
'token' => env('POSTMARK_TOKEN'),
],
'ses' => [
@ -39,8 +35,4 @@ return [
'token' => env('CLOUDCONVERT_API_TOKEN'),
],
'brrr' => [
'webhook_url' => env('BRRR_WEBHOOK_URL'),
],
];

View file

@ -13,8 +13,8 @@ return [
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
| Supported: "file", "cookie", "database", "apc",
| "memcached", "redis", "dynamodb", "array"
|
*/
@ -97,7 +97,7 @@ return [
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
| Affects: "apc", "dynamodb", "memcached", "redis"
|
*/
@ -125,11 +125,12 @@ return [
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
),
/*
@ -152,7 +153,7 @@ return [
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
@ -214,20 +215,4 @@ return [
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];

18
config/ttwitter.php Normal file
View file

@ -0,0 +1,18 @@
<?php
// You can find the keys here : https://dev.twitter.com/
return [
'API_URL' => 'api.twitter.com',
'API_VERSION' => '1.1',
'AUTHENTICATE_URL' => 'https://api.twitter.com/oauth/authenticate',
'AUTHORIZE_URL' => 'https://api.twitter.com/oauth/authorize',
'ACCESS_TOKEN_URL' => 'oauth/access_token',
'REQUEST_TOKEN_URL' => 'oauth/request_token',
'USE_SSL' => true,
'CONSUMER_KEY' => env('TWITTER_CONSUMER_KEY'),
'CONSUMER_SECRET' => env('TWITTER_CONSUMER_SECRET'),
'ACCESS_TOKEN' => env('TWITTER_ACCESS_TOKEN'),
'ACCESS_TOKEN_SECRET' => env('TWITTER_ACCESS_TOKEN_SECRET'),
];

View file

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

View file

@ -2,11 +2,10 @@
namespace Database\Factories;
use App\Models\Bio;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Bio>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bio>
*/
class BioFactory extends Factory
{

View file

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

View file

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

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<Like>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Like>
*/
class LikeFactory extends Factory
{
@ -31,7 +31,7 @@ class LikeFactory extends Factory
'url' => $this->faker->url,
'author_name' => $this->faker->name,
'author_url' => $this->faker->url,
'content' => '<html><body><div class="h-entry"><div class="e-content">'.$this->faker->realtext().'</div></div></body></html>',
'content' => '<html><body><div class="h-entry"><div class="e-content">' . $this->faker->realtext() . '</div></div></body></html>',
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<Media>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Media>
*/
class MediaFactory extends Factory
{
@ -26,7 +26,7 @@ class MediaFactory extends Factory
public function definition(): array
{
return [
'path' => 'media/'.$this->faker->uuid.'.jpg',
'path' => 'media/' . $this->faker->uuid . '.jpg',
'type' => 'image',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),

View file

@ -6,7 +6,7 @@ use App\Models\MicropubClient;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<MicropubClient>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\MicropubClient>
*/
class MicropubClientFactory extends Factory
{

View file

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

View file

@ -6,7 +6,7 @@ use App\Models\Passkey;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Passkey>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Passkey>
*/
class PasskeyFactory extends Factory
{

View file

@ -6,7 +6,7 @@ use App\Models\Place;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Place>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Place>
*/
class PlaceFactory extends Factory
{

View file

@ -2,11 +2,10 @@
namespace Database\Factories;
use App\Models\SyndicationTarget;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SyndicationTarget>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SyndicationTarget>
*/
class SyndicationTargetFactory extends Factory
{

View file

@ -6,7 +6,7 @@ use App\Models\Tag;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Tag>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Tag>
*/
class TagFactory extends Factory
{

View file

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

View file

@ -6,7 +6,7 @@ use App\Models\WebMention;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<WebMention>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WebMention>
*/
class WebMentionFactory extends Factory
{

View file

@ -1,33 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('media_note', function (Blueprint $table) {
$table->id();
$table->foreignId('note_id')->constrained()->onDelete('cascade');
$table->foreignId('media_id')->constrained('media_endpoint')->onDelete('cascade');
$table->text('alt_text')->nullable();
$table->integer('order')->default(0);
$table->timestamps();
$table->unique(['note_id', 'media_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('media_note');
}
};

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('webmentions', function (Blueprint $table) {
$table->text('source')->change();
$table->text('target')->change();
});
}
public function down(): void
{
Schema::table('webmentions', function (Blueprint $table) {
$table->string('source')->change();
$table->string('target')->change();
});
}
};

View file

@ -723,8 +723,8 @@ ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
CREATE TABLE public.webmentions (
id integer NOT NULL,
source text NOT NULL,
target text NOT NULL,
source character varying(255) NOT NULL,
target character varying(255) NOT NULL,
commentable_id integer,
commentable_type character varying(255),
type character varying(255),

View file

@ -4,9 +4,6 @@ namespace Database\Seeders;
use App\Models\Like;
use Faker\Generator;
use Faker\Provider\en_US\Person;
use Faker\Provider\Internet;
use Faker\Provider\Lorem;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@ -22,9 +19,9 @@ class LikesTableSeeder extends Seeder
$now = Carbon::now()->subDays(rand(3, 6));
$faker = new Generator;
$faker->addProvider(new Person($faker));
$faker->addProvider(new Lorem($faker));
$faker->addProvider(new Internet($faker));
$faker->addProvider(new \Faker\Provider\en_US\Person($faker));
$faker->addProvider(new \Faker\Provider\Lorem($faker));
$faker->addProvider(new \Faker\Provider\Internet($faker));
$likeFromAuthor = Like::create([
'url' => $faker->url,
'author_url' => $faker->url,

View file

@ -82,12 +82,10 @@ class NotesTableSeeder extends Seeder
->update(['updated_at' => $now->toDateTimeString()]);
// copy aarons profile pic in place
$spl = new SplFileInfo(public_path().'/assets/profile-images/aaronparecki.com');
$spl = new SplFileInfo(public_path() . '/assets/profile-images/aaronparecki.com');
if ($spl->isDir() === false) {
if (! mkdir($concurrentDirectory = public_path().'/assets/profile-images/aaronparecki.com', 0755) && ! is_dir($concurrentDirectory)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
}
copy(base_path().'/tests/aaron.png', public_path().'/assets/profile-images/aaronparecki.com/image');
mkdir(public_path() . '/assets/profile-images/aaronparecki.com', 0755);
copy(base_path() . '/tests/aaron.png', public_path() . '/assets/profile-images/aaronparecki.com/image');
}
$now = Carbon::now()->subDays(rand(3, 7));
@ -173,9 +171,7 @@ class NotesTableSeeder extends Seeder
$noteWithOnlyImage->setCreatedAt($now);
$noteWithOnlyImage->setUpdatedAt($now);
$noteWithOnlyImage->save();
$noteWithOnlyImage->media()->attach($media->id, [
'alt_text' => 'Test alt text',
]);
$noteWithOnlyImage->media()->save($media);
DB::table('notes')
->where('id', $noteWithOnlyImage->id)
->update(['updated_at' => $now->toDateTimeString()]);

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