Compare commits

...
Author SHA1 Message Date
f9f2744fad
Send Brrr push notifications for new webmentions
Dispatches a queued job to POST to a configured Brrr webhook whenever
a brand-new webmention is saved, so replies/likes/reposts show up as
push notifications instead of requiring a manual check of the site.
2026-08-02 18:07:39 +01:00
31c49ac3fc
Adopt Laravel's Image facade for media processing, upgrade Intervention to v4
Laravel 13's Image facade wraps Intervention Image v4 internally, so
switching our upload width probe and resize job/command to it required
bumping intervention/image ^3 -> ^4 (and its intervention/gif ^5
dependency). Removes our own ImageManager container binding and
config/image.php in favour of Laravel's built-in driver resolution.

Also fixes a latent filename mismatch in ProcessMediaJobTest that Pint's
stricter typing on the new Image API turned into a hard TypeError, and
tidies config/flare.php to use imported class names instead of FQCNs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Sorsgn85nw7uQyRMNvzyD
2026-08-01 17:07:38 +01:00
287520ad7b
Force HTTPS URL generation in production
Uses Laravel's built-in URL::forceHttps() so route(), url(), and asset()
always generate https:// links in production, without affecting local dev.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5TeZbk9KS754xuJMobjE
2026-07-28 19:20:18 +01:00
2eab5749ea
Update dependencies 2026-07-26 10:57:20 +01:00
e08763c526
Migrate outbound HTTP calls from Guzzle to the Http facade
Replaces every raw GuzzleHttp\Client usage (Nominatim, web.archive.org,
webmention fetch/discover/send, Bridgy syndication, IndieAuth client_id
lookup, contact avatar/h-card fetch, profile image download, and the
CloudConvert screenshot pipeline) with Illuminate\Support\Facades\Http,
and enables Http::preventStrayRequests() globally in tests so any
un-faked outbound call now fails loudly instead of silently hitting the
network.

The CloudConvert retry-until-finished middleware in AppServiceProvider
is replaced by a plain polling loop in SaveScreenshot, which also fixes
a latent bug where the old middleware decoded a response object instead
of its body. Bridgy syndication jobs keep their tries=1/no-retry
semantics unchanged to avoid duplicate publishes. Guzzle's PSR-7 helpers
(Header, UriResolver, Utils) stay in SendWebMentions since Http has no
equivalent for them.

Every affected test file's Guzzle MockHandler/HandlerStack boilerplate
is replaced with Http::fake()/Http::sequence().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8j7cJhCiYDsGUgB7obKNQ
2026-07-26 09:12:01 +01:00
c420e19f08
Replace abandoned spatie/commonmark-highlighter with tempest/highlight
Swap the syntax highlighting library and its CSS theme (zenburn -> nord,
matching tempest's class names), add padding to code blocks, and commit
the missing compressed winter.js assets from the earlier asset rename.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 18:37:05 +01:00
f7e13976e0
Rename snow-fall.js asset to winter.js
Some ad/tracker blocklists were matching the "snow-fall" filename and blocking the script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 18:01:57 +01:00
db09718a96
Switch Bridgy syndication from Micropub to webmention
Bridgy's Micropub endpoint required per-silo access tokens and posted
raw markdown content directly. Switch to Bridgy's webmention publish
endpoint instead: POST source/target to /publish/webmention and let
Bridgy re-fetch the note page, which now carries hidden verification
links for silos not yet syndicated. Removes the now-unused
config/bridgy.php and BRIDGY_MASTODON_TOKEN env var, and caps both
jobs to a single try since retries would send Bridgy a duplicate
publish webmention.
2026-07-14 17:36:25 +01:00
11e2c469cb
Fix images overflowing on mobile by preserving aspect ratio
max-width: 100% alone lets img/picture height stay at intrinsic size,
so on narrow viewports images could still overflow vertically or look
squashed. Adding height: auto keeps the aspect ratio correct as width
scales down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 20:02:49 +01:00
4569abc351
Add command to reprocess existing media with correct aspect-ratio scaling
ProcessMedia was using resize(), which distorts images that aren't the
target aspect ratio; scale() preserves it. Existing medium/small
variants generated before this fix need regenerating, so add an
artisan command (with --dry-run) to do that, plus feature tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 20:00:30 +01:00
dd203f6f31
Update dependencies 2026-06-29 20:13:11 +01:00
9f012b01e4
Upgrade to Laravel 13 2026-04-07 09:01:19 +01:00
e8e2bff5e4
Fix UpdateHandler crash when replace.content is a rich-text object
OwnYourSwarm sends content as an array of objects with `value` and `html`
keys per the Micropub spec. The handler now handles both forms: plain
strings and rich objects, preferring `html` over `value`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:43:57 +00:00
4451fc247e
Fix OwnYourSwarm checkin parsing to read from properties.checkin
Real OwnYourSwarm requests send the checkin h-card inside
properties.checkin (as an array), not at the top level. The code
was reading from the top-level key so no Place was ever created,
leaving checkin notes blank.

Update tests to match the real request format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 19:18:00 +00:00
0a13d27d6f
Expand webmentions source and target columns to text
Long brid.gy Bluesky source URLs exceed 255 characters, causing
SQLSTATE[22001] errors. Changed source and target from varchar(255)
to text (no performance impact in PostgreSQL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 16:32:22 +00:00
1137d2a07e
Move request parsing into DTOs via fromRequest(), expand update handler
Each DTO now owns its own parsing logic via fromRequest(), removing
the normalization layer from MicropubRequest entirely. UpdateHandler
gains delete support and allows replace/add/delete to compose in a
single request rather than being mutually exclusive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 20:39:50 +00:00
ad15b36f4d
Introduce typed DTOs and sub-namespaces for Micropub handlers
Each handler now declares the data class it needs via dataClass(). The
controller builds the appropriate typed DTO from the raw request array
before calling handle(), giving handlers typed property access instead
of raw array lookups.

Handlers moved to App\Services\Micropub\Handlers, data objects to
App\Services\Micropub\Data. MicropubHandlerRegistry and the interface
are documented with flow diagrams and guidance for adding new types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:41:33 +00:00
24cb6d61aa
Implement micropub update support with clean exception-based error handling
- Add MicropubUnsupportedModelException for non-note update attempts
- Refactor UpdateHandler to throw exceptions instead of returning JsonResponse objects; extract applySyndication() to DRY up duplicated syndication URL mapping
- Fix MicropubController: InvalidTokenScopeException now returns 401 + insufficient_scope; add catches for ModelNotFoundException (404) and MicropubUnsupportedModelException (500); updates return 200 not 201
- Slim MicropubRequest.normalizeMicropubJson() to branch on action type, keeping update and create fields cleanly separated
- Update tests to match corrected status codes and error keys; remove markTestSkipped() from all update tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:15:35 +00:00
a342b432d4
Update dependencies 2026-02-21 17:06:37 +00:00
a5f146125b
Allow migrate media command to be re-run 2026-01-09 14:21:37 +00:00
4dd4977dd9
Fix issue with migrating old S3 media 2026-01-08 18:21:28 +00:00
d31a59cbbe Merge branch 'main' into develop 2026-01-04 11:29:54 +01:00
51780523b4
Fix unit tests 2026-01-04 10:22:32 +00:00
9e788e0fe8
Images can be uploaded with alt text
This means associating images to notes via a pivot table, we also finish
off the migration of medai from S3.
2026-01-04 10:07:25 +00:00
76db869af5
Allow files to be added to notes
This is specifically when a Micropub client is using the JSON syntax and
initially uploads the photo via the media endpoint
2025-12-27 17:58:43 +00:00
d3d6913474
Switch local setup to PHP8.5
Also pull in config changes from upstream laravel/laravel
2025-12-13 15:53:00 +00:00
69e11d0799 Merge pull request 'MTM Small mobile layout tweaks' (#63) from develop into main
Reviewed-on: #63
2025-12-11 21:13:47 +01:00
6b4607e4f9
Small mobile layout tweaks 2025-12-11 19:58:17 +00:00
aef76e023a Merge pull request 'MTM Fix layout of note metadata' (#61) from develop into main
Reviewed-on: #61
2025-12-11 18:54:12 +01:00
4409e42029 Merge pull request 'MTM Redesign 2025.1' (#57) from develop into main
Reviewed-on: #57
2025-12-11 18:20:26 +01:00
6183a065e9 Merge pull request 'MTM Micropub fixes' (#52) from develop into main
Reviewed-on: #52
2025-08-17 12:18:06 +02:00
0ef822de0c Merge pull request 'MTM No snow' (#49) from develop into main
Reviewed-on: #49
2025-07-01 17:57:22 +02:00
07db68882b Merge pull request 'MTM Laravel v12' (#31) from develop into main
Some checks failed
Deploy / Deploy (release) Has been cancelled
Reviewed-on: #31
2025-04-04 18:10:46 +02:00
13786584a2 Merge pull request 'MTM WebMention username fix & snow fall' (#21) from develop into main
Some checks failed
Deploy / Deploy (release) Has been cancelled
Reviewed-on: #21
2024-11-30 17:01:53 +01:00
6d867fcc71 Merge pull request 'MTM Host images locally' (#12) from develop into main
Some checks failed
Deploy / Deploy (release) Has been cancelled
Reviewed-on: #12
2024-10-26 15:02:56 +02:00
7dd85a3988 Merge pull request 'MTM Improve customisability' (#8) from develop into main
Some checks failed
Deploy / Deploy (release) Has been cancelled
Reviewed-on: #8
2024-08-29 19:25:33 +02:00
863db8e491 Merge pull request 'MTM Fix how scopes are stored in the IndieAuth flow' (#4) from develop into main
Some checks failed
Deploy / Deploy (release) Has been cancelled
Reviewed-on: #4
2024-08-03 14:28:48 +02:00
b7d7db60e7
Merge pull request #1474 from jonnybarnes/develop
MTM Fix scope checks
2024-07-13 15:20:46 +01:00
0336744322
Merge pull request #1454 from jonnybarnes/develop
MTM Fix issuing of tokens
2024-06-30 11:20:41 +01:00
7b13f91cc4
Merge pull request #1445 from jonnybarnes/develop
MTM Parse scope value from authorization URL correctly
2024-06-22 21:01:34 +01:00
f689128afc
Merge pull request #1442 from jonnybarnes/develop
MTM Save full url during login
2024-06-22 20:33:36 +01:00
2e624d9565
Merge pull request #1439 from jonnybarnes/develop
Improve IndieAuth links in the html head
2024-06-22 18:46:16 +01:00
d60117cd1d
Merge pull request #1436 from jonnybarnes/develop
New IndieAuth endpoint
2024-06-22 18:29:29 +01:00
fabe9d662b
Merge pull request #1371 from jonnybarnes/develop
MTM Bluesky Syndication
2024-03-23 21:33:39 +00:00
507bac08f2
Merge pull request #1363 from jonnybarnes/develop
MTM Remove CSP header
2024-03-23 15:13:55 +00:00
c0802f8792
Merge pull request #1361 from jonnybarnes/develop
MTM Fix some endpoints
2024-03-23 14:29:33 +00:00
c907bcde0c
Merge pull request #1357 from jonnybarnes/develop
MTM Update CSP
2024-03-22 19:17:22 +00:00
784f4cb44a
Merge pull request #1354 from jonnybarnes/develop
MTM Fix showing webmentions
2024-03-22 19:00:39 +00:00
528e981d7c
Merge pull request #1352 from jonnybarnes/develop
MTM Add Flare config back
2024-03-22 18:33:45 +00:00
5f8d6ca882
Merge pull request #1348 from jonnybarnes/develop
MTM Fix WebMentions
2024-03-22 17:53:24 +00:00
d56306e57b
Merge pull request #1346 from jonnybarnes/develop
MTM Laravel 11
2024-03-22 15:31:33 +00:00
43c654d9e7
Merge pull request #1316 from jonnybarnes/develop
MTM Fix error in SendWebMention job
2024-03-02 10:21:49 +00:00
a7c8e58abd
Merge pull request #1303 from jonnybarnes/develop
MTM Fix fonts
2024-02-23 16:50:55 +00:00
b40f10cada
Merge pull request #1300 from jonnybarnes/develop
MTM Switch fonts
2024-02-23 16:41:54 +00:00
deba175f8f
Merge pull request #1288 from jonnybarnes/develop
MTM: Remove snow effect
2024-02-17 18:28:13 +00:00
c0c8e5262b
Merge pull request #1206 from jonnybarnes/develop
MTM Simplify build steps
2023-12-22 08:03:01 +00:00
469d11ca7a
Merge pull request #1191 from jonnybarnes/develop
MTM Hopefully fix snow
2023-12-17 15:01:17 +00:00
6ff247d58b
Merge pull request #1188 from jonnybarnes/develop
MTM Add snow
2023-12-17 14:45:57 +00:00
d5bac002c4
Merge pull request #1155 from jonnybarnes/develop
MTM Show Webmentions
2023-12-02 14:53:51 +00:00
481d9e22bb
Merge pull request #1123 from jonnybarnes/develop
MTM Updated CSS and Deploy script
2023-11-11 19:25:04 +00:00
335acb130e
Merge pull request #1103 from jonnybarnes/develop
MTM Passkey Support
2023-10-27 20:17:14 +00:00
f375e4217f
Merge pull request #922 from jonnybarnes/develop
MTM Fix error in SaveProfileImage
2023-06-17 20:01:52 +01:00
3dfe99aac7
Merge pull request #919 from jonnybarnes/develop
MTM Fix routing for Article links
2023-06-16 13:18:28 +01:00
577d821709
Merge pull request #909 from jonnybarnes/develop
MTM Fix use of config variables
2023-06-11 17:01:56 +01:00
66f4f856a6
Merge pull request #906 from jonnybarnes/develop
MTM Stop note IDs being too large
2023-06-11 15:22:55 +01:00
73bc0b5338
Merge pull request #901 from jonnybarnes/develop
MTM Add Flare support
2023-06-09 14:15:30 +01:00
2f40e83568
Merge pull request #874 from jonnybarnes/develop
MTM Use separate CSS file in production
2023-05-29 13:04:38 +01:00
b2ef970810
Merge pull request #865 from jonnybarnes/develop
MTM Fix CSP for webpack added style rules
2023-05-27 19:40:02 +01:00
2b2826212f
Merge pull request #863 from jonnybarnes/develop
MTM Fix compiled assets for prod
2023-05-27 18:08:09 +01:00
94bb8c204b
Merge pull request #860 from jonnybarnes/develop
MTM Fix schema update
2023-05-27 16:58:22 +01:00
87d49bddde
Merge pull request #857 from jonnybarnes/develop
MTM: Some small tweaks and dependency updates
2023-05-27 15:41:41 +01:00
9383ed8d0b
Merge pull request #817 from jonnybarnes/develop
MTM Remove Mapbox links
2023-05-04 18:49:21 +01:00
2962675f9d
Merge pull request #770 from jonnybarnes/develop
MTM Re-add search functionality
2023-04-12 10:19:14 +01:00
8532ee36d3
Merge pull request #766 from jonnybarnes/develop
MTM Switch bio to be stored in the database
2023-04-11 17:50:34 +01:00
22447b6027
Merge pull request #758 from jonnybarnes/develop
MTM 2023 Redesign v1
2023-04-08 17:07:18 +01:00
fcf0b4a778
Merge pull request #670 from jonnybarnes/develop
MTM: Laravel 10
2023-02-18 10:03:06 +00:00
cc782bc39a
Merge pull request #641 from jonnybarnes/develop
MTM: Add PHP 8.2 Support
2023-02-04 12:45:44 +00:00
054bba1da9
Merge pull request #639 from jonnybarnes/develop
MTM: Recent work
2023-02-04 12:22:23 +00:00
22e3eaab23
Merge pull request #559 from jonnybarnes/develop
MTM Update Failed Jobs table
2022-11-21 19:15:09 +00:00
e0efd19ef9
Merge pull request #553 from jonnybarnes/develop
MTM More Media Endpoint Improvements
2022-11-20 17:30:25 +00:00
0cd6f81a09
Merge pull request #551 from jonnybarnes/develop
MTM Improve Media Endpoint
2022-11-18 17:32:39 +00:00
30c9b2e248
Merge pull request #535 from jonnybarnes/develop
MTM Handle finding webmention targets better
2022-11-09 20:10:55 +00:00
52ac67ba7a
Merge pull request #525 from jonnybarnes/develop
MTM Post Markdown content to Mastodon
2022-11-06 10:32:03 +00:00
272a5ea2fd
Merge pull request #522 from jonnybarnes/develop
MTM Mastodon syndication
2022-11-04 17:22:22 +00:00
0bea4a1e95
Merge pull request #499 from jonnybarnes/develop
MTM Simplify migrations
2022-10-24 21:18:58 +01:00
8ec738a765
Merge pull request #497 from jonnybarnes/develop
MTM Fix error saving syndication data
2022-10-24 17:45:22 +01:00
2f548725db
Merge pull request #494 from jonnybarnes/develop
MTM Store synidaction targets in the database
2022-10-24 14:28:06 +01:00
f6546e2335
Merge pull request #477 from jonnybarnes/develop
MTM Update Twitter syndication logo
2022-10-16 18:16:38 +01:00
d52b8a3e83
Merge pull request #473 from jonnybarnes/develop
MTM Fix IndieAuth sign in
2022-10-15 16:24:35 +01:00
46133be181
Merge pull request #445 from jonnybarnes/develop
MTM Reworked indieauth checking in token endpoint
2022-09-24 19:23:18 +01:00
0faf896e8f
Merge pull request #443 from jonnybarnes/develop
MTM Fix IndieAuth sign in
2022-09-24 18:42:27 +01:00
374e231a6b
Merge pull request #420 from jonnybarnes/develop
MTM Use env for cookie prefix
2022-09-10 13:50:49 +01:00
729c52df2f
Merge pull request #418 from jonnybarnes/develop
MTM JWT and Cookie fixes
2022-09-10 13:31:24 +01:00
ac19c02e76
Merge pull request #380 from jonnybarnes/develop
MTM More link fixes
2022-08-21 09:00:38 +01:00
0289f9f0b0
Merge pull request #378 from jonnybarnes/develop
MTM Fixing links
2022-08-20 15:31:34 +01:00
14a10472a6
Merge pull request #365 from jonnybarnes/develop
MTM Add tagged bookmarks page
2022-08-14 18:03:30 +01:00
b74bdd01ef
Merge pull request #362 from jonnybarnes/develop
MTM: Fix some links
2022-08-13 20:30:08 +01:00
9c059f822f
Merge pull request #293 from jonnybarnes/develop
MTM Mentions Improvements
2022-07-09 12:03:07 +01:00
75e369992d
Merge pull request #268 from jonnybarnes/develop
MTM Deploy tags
2022-06-04 17:24:37 +01:00
a18f7b2d03
Merge pull request #265 from jonnybarnes/develop
MTM Dependency updates and HTML Sanitizer
2022-06-04 11:16:23 +01:00
7d1738964a
Merge pull request #261 from jonnybarnes/develop
MTM: Laravel 9
2022-05-15 17:20:12 +01:00
77004ec7d4
Merge pull request #246 from jonnybarnes/develop
MTM: Syndication links style fix
2022-01-08 21:25:14 +00:00
3f891b580c
Merge pull request #244 from jonnybarnes/develop
MTM: Various dependency updates and new deployment code
2022-01-08 19:58:35 +00:00
38f3244e31
Merge pull request #220 from jonnybarnes/develop
MTM: PHP8 support, and PostCSS Webpack work
2020-12-31 14:55:46 +00:00
650d54292e
Merge pull request #215 from jonnybarnes/develop
MTM: Get main branch up to date
2020-11-08 11:14:29 +00:00
c40548be73
Merge pull request #114 from jonnybarnes/develop
MTM: recent work on Horizon
2019-03-23 16:10:06 +00:00
169 changed files with 5995 additions and 5218 deletions

View file

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

View file

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

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

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

View file

@ -6,10 +6,11 @@ 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
@ -103,7 +104,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 \Illuminate\Http\RedirectResponse|\Illuminate\View\View
* @return RedirectResponse|View
*/
public function getAvatar(int $contactId)
{
@ -112,14 +113,13 @@ 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 = $client->get($contact->homepage);
} catch (\GuzzleHttp\Exception\BadResponseException $e) {
$response = Http::throw()->get($contact->homepage);
} catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Bad resposne from contacts homepage');
}
$mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage);
$mf2 = \Mf2\parse($response->body(), $contact->homepage);
foreach ($mf2['items'] as $microformat) {
if (Arr::get($microformat, 'type.0') === 'h-card') {
$avatarURL = Arr::get($microformat, 'properties.photo.0.value');
@ -128,8 +128,8 @@ class ContactsController extends Controller
}
if ($avatarURL !== null) {
try {
$avatar = $client->get($avatarURL);
} catch (\GuzzleHttp\Exception\BadResponseException $e) {
$avatar = Http::throw()->get($avatarURL);
} catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Unable to download avatar');
}
@ -140,7 +140,7 @@ class ContactsController extends Controller
if ($filesystem->isDirectory($directory) === false) {
$filesystem->makeDirectory($directory);
}
$filesystem->put($directory . '/image', $avatar->getBody());
$filesystem->put($directory.'/image', $avatar->body());
return view('admin.contacts.getavatarsuccess', [
'homepage' => parse_url($contact->homepage, PHP_URL_HOST),

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

View file

@ -5,13 +5,12 @@ 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;
@ -199,15 +198,13 @@ 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 = $guzzle->get($clientId);
} catch (Exception) {
$clientInfo = Http::throw()->get($clientId);
} catch (\Throwable) {
return false;
}
$clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId);
$clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId);
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];

View file

@ -6,10 +6,13 @@ 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;
@ -26,9 +29,9 @@ class MicropubController extends Controller
/**
* Respond to a POST request to the micropub endpoint.
*
* The request is initially processed by the MicropubRequest form request
* class. The normalizes the data, so we can pass it into the handlers for
* the different micropub requests, h-entry or h-card, for example.
* 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().
*/
public function post(MicropubRequest $request): JsonResponse
{
@ -43,13 +46,36 @@ class MicropubController extends Controller
try {
$handler = $this->handlerRegistry->getHandler($type);
$result = $handler->handle($request->getMicropubData());
$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']);
}
// 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',
@ -57,15 +83,10 @@ class MicropubController extends Controller
], 400);
} catch (MicropubHandlerException) {
return response()->json([
'error' => 'Unknown Micropub type',
'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server',
], 500);
} catch (InvalidTokenScopeException) {
return response()->json([
'error' => 'invalid_scope',
'error_description' => 'The token does not have the required scope for this request',
], 403);
} catch (\Exception) {
} catch (\Exception $e) {
return response()->json([
'error' => 'server_error',
'error_description' => 'An error occurred processing the request',
@ -76,10 +97,9 @@ class MicropubController extends Controller
/**
* Respond to a GET request to the micropub endpoint.
*
* 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.
* 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.
*/
public function get(Request $request): JsonResponse
{

View file

@ -13,9 +13,10 @@ 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
@ -111,12 +112,9 @@ class MicropubMediaController extends Controller
$filename = Storage::disk('local')->putFile('media', $file);
/** @var ImageManager $manager */
$manager = resolve(ImageManager::class);
try {
$image = $manager->read($request->file('file'));
$width = $image->width();
} catch (Exception) {
$width = Image::fromUpload($request->file('file'))->width();
} catch (ImageException) {
// not an image
$width = null;
}

View file

@ -5,7 +5,6 @@ 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;
@ -53,7 +52,7 @@ class NotesController extends Controller
->withCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}])->firstOrFail();
} catch (ModelNotFoundException $exception) {
} catch (\Exception) {
abort(404);
}

View file

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

View file

@ -5,12 +5,9 @@ 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 [
@ -18,102 +15,25 @@ 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()) {
$this->normalizeMicropubJson();
} else {
$this->normalizeMicropubForm();
}
$data = $this->json()->all();
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';
}
// 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;
return substr($type, 2);
}
}
private function getLocationData(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');
return $this->input('h') ?: null;
}
}

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,15 +29,15 @@ class DownloadWebMention implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
* @throws RequestException
* @throws FileNotFoundException
*/
public function handle(Client $guzzle): void
public function handle(): void
{
$response = $guzzle->request('GET', $this->source);
// 4XX and 5XX responses should get Guzzle to throw an exception,
// Laravel should catch and retry these automatically.
if ($response->getStatusCode() === 200) {
// 4XX and 5XX responses should throw so Laravel can catch and
// retry these automatically.
$response = Http::throw()->get($this->source);
if ($response->status() === 200) {
$filesystem = new FileSystem;
$filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source);
// backup file first
@ -56,7 +56,7 @@ class DownloadWebMention implements ShouldQueue
// save new HTML
$filesystem->put(
$filename,
(string) $response->getBody()
$response->body()
);
// remove backup if the same
if ($filesystem->exists($filenameBackup)) {

View file

@ -0,0 +1,56 @@
<?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,16 +5,13 @@ 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;
@ -34,42 +31,11 @@ class ProcessLike implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Client $client, Authorship $authorship): int
public function handle(Authorship $authorship): int
{
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);
$response = Http::throw()->get($this->like->url);
$mf2 = \Mf2\parse($response->body(), $this->like->url);
if (Arr::has($mf2, 'items.0.properties.content')) {
$this->like->content = $mf2['items'][0]['properties']['content'][0]['html'];
}
@ -91,15 +57,4 @@ 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,40 +30,38 @@ class ProcessMedia implements ShouldQueue
/**
* Execute the job.
*/
public function handle(ImageManager $manager): void
public function handle(): void
{
// Load file
$file = Storage::disk('local')->get('media/' . $this->filename);
$file = Storage::disk('local')->get($this->filename);
// Open file
$image = Image::fromStorage($this->filename, 'local');
try {
$image = $manager->read($file);
} catch (DecoderException) {
$width = $image->width();
} catch (ImageException) {
// not an image; delete file and end job
Storage::disk('local')->delete('media/' . $this->filename);
Storage::disk('local')->delete($this->filename);
return;
}
// Save the file publicly
Storage::disk('public')->put('media/' . $this->filename, $file);
Storage::disk('public')->put($this->filename, $file);
// Create smaller versions if necessary
if ($image->width() > 1000) {
if ($width > 1000) {
$filenameParts = explode('.', $this->filename);
$extension = array_pop($filenameParts);
// the following achieves this data flow
// foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
$basename = trim(implode('.', $filenameParts), '.');
$medium = $image->resize(width: 1000);
Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode());
$small = $image->resize(width: 500);
Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode());
Storage::disk('public')->put($basename.'-medium.'.$extension, $image->scale(width: 1000)->toBytes());
Storage::disk('public')->put($basename.'-small.'.$extension, $image->scale(width: 500)->toBytes());
}
// Now we can delete the locally saved image
Storage::disk('local')->delete('media/' . $this->filename);
Storage::disk('local')->delete($this->filename);
}
}

View file

@ -7,13 +7,12 @@ 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;
@ -36,18 +35,20 @@ class ProcessWebMention implements ShouldQueue
* Execute the job.
*
* @throws RemoteContentNotFoundException
* @throws GuzzleException
* @throws InvalidMentionException
*/
public function handle(Parser $parser, Client $guzzle): void
public function handle(Parser $parser): void
{
try {
$response = $guzzle->request('GET', $this->source);
} catch (RequestException $e) {
$response = Http::get($this->source);
} catch (ConnectionException) {
throw new RemoteContentNotFoundException;
}
$this->saveRemoteContent((string) $response->getBody(), $this->source);
$microformats = Mf2\parse((string) $response->getBody(), $this->source);
if ($response->failed()) {
throw new RemoteContentNotFoundException;
}
$this->saveRemoteContent($response->body(), $this->source);
$microformats = Mf2\parse($response->body(), $this->source);
$webmentions = WebMention::where('source', $this->source)->get();
foreach ($webmentions as $webmention) {
// check webmention still references target
@ -95,6 +96,7 @@ class ProcessWebMention implements ShouldQueue
$webmention->type = $type;
$webmention->mf2 = json_encode($microformats);
$webmention->save();
dispatch(new NotifyBrrrOfWebMention($webmention));
}
/**

View file

@ -4,13 +4,14 @@ 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;
@ -55,12 +56,10 @@ 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 = $client->get($photo);
$image = $response->getBody();
} catch (RequestException) {
$response = Http::throw()->get($photo);
$image = $response->body();
} catch (ConnectionException|RequestException) {
// we are opening and reading the default image so that
$default = public_path().'/assets/profile-images/default-image';
$handle = fopen($default, 'rb');

View file

@ -5,14 +5,15 @@ 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
{
@ -27,77 +28,68 @@ class SaveScreenshot implements ShouldQueue
/**
* Execute the job.
*
*
* @throws JsonException
*/
public function handle(): void
{
// A normal Guzzle client
$client = resolve(Client::class);
// A Guzzle client with a custom Middleware to retry the CloudConvert API requests
$retryClient = resolve('RetryGuzzle');
$cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2')
->withToken(config('services.cloudconvert.token'))
->throw();
// First request that CloudConvert takes a screenshot of the URL
$takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'json' => [
$takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [
'url' => $this->bookmark->url,
'output_format' => 'png',
'screen_width' => 1440,
'screen_height' => 900,
'wait_until' => 'networkidle0',
'wait_time' => 100,
],
]);
$taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
$taskId = $takeScreenshotJobResponse->json('data.id');
// Now wait till the status job is finished
$screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $taskId, [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
$screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId);
$finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
$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 = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'json' => [
$exportImageJob = $cloudConvert->post('/export/url', [
'input' => $finishedCaptureId,
'archive_multiple_files' => false,
],
]);
$exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
$exportImageJobId = $exportImageJob->json('data.id');
// Again, wait till the status of this export job is finished
$finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $exportImageJobId, [
'headers' => [
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
$finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId);
// Now we can download the screenshot and save it to the storage
$finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url;
$finalImageUrl = $finalImageUrlResponse->json('data.result.files.0.url');
$finalImageUrlContent = $client->request('GET', $finalImageUrl);
$finalImageUrlContent = Http::throw()->get($finalImageUrl);
Storage::disk('public')->put('/assets/img/bookmarks/' . $taskId . '.png', $finalImageUrlContent->getBody()->getContents());
Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->body());
$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,8 +5,6 @@ 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;
@ -14,7 +12,9 @@ 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,8 +31,6 @@ class SendWebMentions implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(): void
{
@ -42,12 +40,9 @@ class SendWebMentions implements ShouldQueue
foreach ($urls as $url) {
$endpoint = $this->discoverWebmentionEndpoint($url);
if ($endpoint !== null) {
$guzzle = resolve(Client::class);
$guzzle->post($endpoint, [
'form_params' => [
Http::asForm()->post($endpoint, [
'source' => $this->note->uri,
'target' => $url,
],
]);
}
}
@ -55,8 +50,6 @@ class SendWebMentions implements ShouldQueue
/**
* Discover if a URL has a webmention endpoint.
*
* @throws GuzzleException
*/
public function discoverWebmentionEndpoint(string $url): ?string
{
@ -70,10 +63,9 @@ class SendWebMentions implements ShouldQueue
$endpoint = null;
$guzzle = resolve(Client::class);
$response = $guzzle->get($url);
$response = Http::get($url);
// check HTTP Headers for webmention endpoint
$links = Header::parse($response->getHeader('Link'));
$links = Header::parse($response->header('Link'));
foreach ($links as $link) {
if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) {
return $this->resolveUri(trim($link[0], '<>'), $url);
@ -81,9 +73,13 @@ class SendWebMentions implements ShouldQueue
}
// failed to find a header so parse HTML
$html = (string) $response->getBody();
$html = $response->body();
$mf2 = new \Mf2\Parser($html, $url);
if ($html === '') {
return null;
}
$mf2 = new Parser($html, $url);
$rels = $mf2->parseRelsAndAlternates();
if (array_key_exists('webmention', $rels[0])) {
$endpoint = $rels[0]['webmention'][0];

View file

@ -5,18 +5,22 @@ 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.
*/
@ -26,37 +30,26 @@ class SyndicateNoteToBluesky implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Client $guzzle): void
public function handle(): void
{
// We can only make the request if we have an access token
if (config('bridgy.bluesky_token') === null) {
// 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();
return;
}
// Make micropub request
$response = $guzzle->request(
'POST',
'https://brid.gy/micropub',
[
'headers' => [
'Authorization' => 'Bearer ' . config('bridgy.bluesky_token'),
],
'json' => [
'type' => ['h-entry'],
'properties' => [
'content' => [$this->note->getRawOriginal('note')],
],
],
]
throw new \RuntimeException(
'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body())
);
// Parse for syndication URL
if ($response->getStatusCode() === 201) {
$this->note->bluesky_url = $response->getHeader('Location')[0];
$this->note->save();
}
}
}

View file

@ -5,18 +5,22 @@ 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.
*/
@ -26,38 +30,26 @@ class SyndicateNoteToMastodon implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Client $guzzle): void
public function handle(): void
{
// We can only make the request if we have an access token
if (config('bridgy.mastodon_token') === null) {
// 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();
return;
}
// 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')],
],
],
]
throw new \RuntimeException(
'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body())
);
// Parse for syndication URL
if ($response->getStatusCode() === 201) {
$mastodonUrl = $response->getHeader('Location')[0];
$this->note->mastodon_url = $mastodonUrl;
$this->note->save();
}
}
}

View file

@ -5,6 +5,8 @@ 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;
@ -12,29 +14,17 @@ 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 Spatie\CommonMarkHighlighter\FencedCodeRenderer;
use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
use Tempest\Highlight\CommonMark\HighlightExtension;
#[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',
@ -60,8 +50,7 @@ class Article extends Model
get: function () {
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer);
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer);
$environment->addExtension(new HighlightExtension);
$markdownConverter = new MarkdownConverter($environment);
return $markdownConverter->convert($this->main)->getContent();

View file

@ -4,18 +4,17 @@ 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',
@ -26,7 +25,7 @@ class Bookmark extends Model
return $this->belongsToMany('App\Models\Tag');
}
protected function local_uri(): Attribute
protected function localUri(): Attribute
{
return Attribute::get(
get: fn () => config('app.url').'/bookmarks/'.$this->id,

View file

@ -4,20 +4,18 @@ 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';

View file

@ -5,20 +5,19 @@ 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
#[Table('media_endpoint')]
#[Fillable(['token', 'path', 'type', 'image_widths'])]
class Media extends Model
{
use HasFactory;
/** @var string */
protected $table = 'media_endpoint';
/** @var array<int, string> */
protected $fillable = ['token', 'path', 'type', 'image_widths'];
public function note(): BelongsTo
public function notes(): BelongsToMany
{
return $this->belongsTo(Note::class);
return $this->belongsToMany(Note::class)
->withPivot('alt_text', 'order')
->withTimestamps();
}
protected function url(): Attribute

View file

@ -4,20 +4,18 @@ 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,32 +6,35 @@ namespace App\Models;
use App\CommonMark\Generators\MentionGenerator;
use App\CommonMark\Renderers\MentionRenderer;
use Codebird\Codebird;
use Exception;
use GuzzleHttp\Client;
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 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 Spatie\CommonMarkHighlighter\FencedCodeRenderer;
use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
use Tempest\Highlight\CommonMark\HighlightExtension;
#[Table('notes')]
#[Fillable(['note', 'in_reply_to', 'client_id'])]
#[Hidden(['searchable'])]
#[ObservedBy(NoteObserver::class)]
class Note extends Model
{
use HasFactory;
@ -62,16 +65,6 @@ 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);
@ -92,9 +85,12 @@ class Note extends Model
return $this->belongsTo(Place::class);
}
public function media(): HasMany
public function media(): BelongsToMany
{
return $this->hasMany(Media::class);
return $this->BelongsToMany(Media::class)
->withPivot('alt_text', 'order')
->withTimestamps()
->orderBy('order');
}
/**
@ -239,43 +235,6 @@ 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.
*/
@ -392,9 +351,8 @@ 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();
@ -405,18 +363,15 @@ class Note extends Model
$latLng = $latitude.','.$longitude;
return Cache::get($latLng, function () use ($latLng, $latitude, $longitude) {
$guzzle = resolve(Client::class);
$response = $guzzle->request('GET', 'https://nominatim.openstreetmap.org/reverse', [
'query' => [
$response = Http::withHeaders(['User-Agent' => 'jonnybarnes.uk, email jonny@jonnybarnes.uk'])
->get('https://nominatim.openstreetmap.org/reverse', [
'format' => 'json',
'lat' => $latitude,
'lon' => $longitude,
'zoom' => 18,
'addressdetails' => 1,
],
'headers' => ['User-Agent' => 'jonnybarnes.uk via Guzzle, email jonny@jonnybarnes.uk'],
]);
$json = json_decode((string) $response->getBody());
$json = $response->object();
if (isset($json->address->suburb)) {
$locality = $json->address->suburb;
if (isset($json->address->city)) {
@ -457,9 +412,4 @@ class Note extends Model
return $address;
});
}
private function isTwitterLink(string $inReplyTo): bool
{
return str_starts_with($inReplyTo, 'https://twitter.com/');
}
}

View file

@ -4,20 +4,16 @@ 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,6 +5,7 @@ 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;
@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'slug'])]
class Place extends Model
{
use HasFactory;
@ -22,9 +24,6 @@ class Place extends Model
return 'slug';
}
/** @var array<int, string> */
protected $fillable = ['name', 'slug'];
/** @var array<string, string> */
protected $casts = [
'latitude' => 'float',

View file

@ -4,40 +4,20 @@ 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,19 +4,18 @@ 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,27 +4,34 @@ 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;
use Notifiable;
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/** @var array<int, string> */
protected $fillable = [
'name', 'password',
];
/** @var array<int, string> */
protected $hidden = [
'current_password',
'password',
'remember_token',
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function passkey(): HasMany
{

View file

@ -5,27 +5,23 @@ 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();
@ -127,19 +123,6 @@ 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';

View file

@ -2,14 +2,10 @@
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;
@ -33,33 +29,6 @@ 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.
*
@ -104,39 +73,10 @@ 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,8 +4,9 @@ declare(strict_types=1);
namespace App\Providers;
use App\Services\Micropub\CardHandler;
use App\Services\Micropub\EntryHandler;
use App\Services\Micropub\Handlers\CardHandler;
use App\Services\Micropub\Handlers\EntryHandler;
use App\Services\Micropub\Handlers\UpdateHandler;
use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Support\ServiceProvider;
@ -19,6 +20,7 @@ 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,10 +8,8 @@ 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
@ -55,21 +53,19 @@ class BookmarkService
* Given a URL, attempt to save it to the Internet Archive.
*
* @throws InternetArchiveException
* @throws GuzzleException
*/
public function getArchiveLink(string $url): string
{
$client = resolve(Client::class);
try {
$response = $client->request('GET', 'https://web.archive.org/save/' . $url);
} catch (ClientException $e) {
$response = Http::get('https://web.archive.org/save/'.$url);
if ($response->clientError()) {
// throw an exception to be caught
throw new InternetArchiveException;
}
if ($response->hasHeader('Content-Location')) {
if (Str::startsWith(Arr::get($response->getHeader('Content-Location'), 0), '/web')) {
return $response->getHeader('Content-Location')[0];
}
$contentLocation = $response->header('Content-Location');
if ($contentLocation !== '' && Str::startsWith($contentLocation, '/web')) {
return $contentLocation;
}
// throw an exception to be caught

View file

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

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

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

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

View file

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

View file

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

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

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

View file

@ -5,7 +5,27 @@ 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
{
/**
@ -13,6 +33,9 @@ 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;
@ -21,6 +44,8 @@ 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

@ -1,119 +0,0 @@
<?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,12 +48,15 @@ class NoteService
$note->place()->associate($this->getCheckin($data));
$note->swarm_url = $this->getSwarmUrl($data);
}
//
// $note->instagram_url = $this->getInstagramUrl($request);
//
// foreach ($this->getMedia($request) as $media) {
// $note->media()->save($media);
// }
foreach ($this->getMedia($data) as $index => $media) {
$note->media()->attach($media['value']->id, [
'alt_text' => $media['alt'],
'order' => $index,
]);
}
$note->save();
@ -188,23 +191,44 @@ class NoteService
/**
* Get the media URLs from the request to create a new note.
*/
private function getMedia(array $request): array
private function getMedia(array $data): array
{
$media = [];
$photos = Arr::get($request, 'photo') ?? Arr::get($request, 'properties.photo');
$photos = Arr::get($data, 'photos');
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($photo, config('filesystems.disks.s3.url'))) {
$path = substr($photo, strlen(config('filesystems.disks.s3.url')));
$media[] = Media::where('path', ltrim($path, '/'))->firstOrFail();
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,
];
} else {
$newMedia = Media::firstOrNew(['path' => $photo]);
$newMedia = Media::firstOrNew(['path' => $photoUrl]);
// currently assuming this is a photo from Swarm or OwnYourGram
$newMedia->type = 'image';
$newMedia->save();
$media[] = $newMedia;
$media[] = [
'value' => $newMedia,
'alt' => $photoAlt,
];
}
}
}

View file

@ -15,14 +15,17 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware
->append(LinkHeadersMiddleware::class)
->validateCsrfTokens(except: [
->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
);
})
->withExceptions(function (Exceptions $exceptions) {
Flare::handles($exceptions);

View file

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

View file

@ -6,49 +6,43 @@
"keywords": ["laravel", "framework", "indieweb"],
"license": "CC0-1.0",
"require": {
"php": "^8.2",
"php": "^8.3",
"ext-dom": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pgsql": "*",
"ext-sodium": "*",
"cviebrock/eloquent-sluggable": "^12.0",
"guzzlehttp/guzzle": "^7.2",
"cviebrock/eloquent-sluggable": "^13.0",
"indieauth/client": "^1.1",
"intervention/image": "^3",
"intervention/image": "^4.0",
"jonnybarnes/indieweb": "~0.2",
"jonnybarnes/webmentions-parser": "~0.5",
"jublonet/codebird-php": "4.0.0-beta.1",
"laravel/framework": "^12.0",
"laravel/framework": "^13.0",
"laravel/horizon": "^5.0",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.1",
"laravel/tinker": "^2.8",
"laravel/tinker": "^3.0",
"lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3",
"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",
"spatie/laravel-flare": "^3.0",
"symfony/html-sanitizer": "^8.0",
"tempest/highlight": "^2.27",
"web-auth/webauthn-lib": "^5.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.0",
"barryvdh/laravel-debugbar": "^4.0.9",
"barryvdh/laravel-ide-helper": "^3.0",
"fakerphp/faker": "^1.9.2",
"fakerphp/faker": "^1.23",
"laravel/dusk": "^8.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",
"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",
"spatie/laravel-ray": "^1.12",
"spatie/x-ray": "^1.2"
},
@ -68,6 +62,22 @@
}
},
"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"
@ -83,9 +93,8 @@
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"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"
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
@ -99,8 +108,7 @@
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"composer/installers": true
"php-http/discovery": true
}
},
"minimum-stability": "stable",

4349
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(',', env('APP_PREVIOUS_KEYS', ''))
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],

View file

@ -1,5 +1,7 @@
<?php
use App\Models\User;
return [
/*
@ -62,7 +64,7 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
@ -104,7 +106,7 @@ return [
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the amount of seconds before a password confirmation
| Here you may define the number 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.
|

View file

@ -1,31 +0,0 @@
<?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,8 +26,9 @@ 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: "apc", "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", "null"
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
@ -40,9 +41,10 @@ return [
'database' => [
'driver' => 'database',
'table' => env('DB_CACHE_TABLE', 'cache'),
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
@ -89,6 +91,14 @@ return [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
@ -102,6 +112,19 @@ return [
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
'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,
];

View file

@ -1,6 +1,7 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
@ -40,6 +41,7 @@ return [
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
@ -58,7 +60,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@ -78,7 +80,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@ -94,7 +96,7 @@ return [
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
@ -147,7 +149,7 @@ return [
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
@ -158,6 +160,10 @@ 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' => [
@ -167,6 +173,10 @@ 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

@ -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' => env('APP_URL').'/storage',
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,

View file

@ -1,8 +1,8 @@
<?php
use Spatie\FlareClient\Api;
use Spatie\LaravelFlare\AttributesProviders\LaravelUserAttributesProvider;
use Spatie\FlareClient\Sampling\RateSampler;
use Spatie\LaravelFlare\FlareConfig;
use Spatie\LaravelFlare\Senders\LaravelHttpSender;
return [
/*
@ -19,17 +19,6 @@ 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
@ -45,22 +34,6 @@ 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
@ -86,19 +59,49 @@ return [
'X-XSRF-TOKEN',
],
'client_ips' => false,
'cookies' => false,
'session' => false,
],
/*
|--------------------------------------------------------------------------
| Reporting log statements
| Sender
|--------------------------------------------------------------------------
|
| 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.
| 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.
|
*/
'send_logs_as_events' => true,
'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),
/*
|--------------------------------------------------------------------------
@ -111,19 +114,6 @@ return [
'report_error_levels' => null,
/*
|--------------------------------------------------------------------------
| Share button
|--------------------------------------------------------------------------
|
| Flare automatically adds a Share button to the laravel error page. This
| button allows you to easily share errors with colleagues or friends. It
| is enabled by default, but you can disable it here.
|
*/
'enable_share_button' => true,
/*
|--------------------------------------------------------------------------
| Override grouping
@ -143,20 +133,16 @@ return [
/*
|--------------------------------------------------------------------------
| Sender
| Share button
|--------------------------------------------------------------------------
|
| The sender is responsible for sending the error reports and traces to
| Flare it can be configured if needed.
| Flare automatically adds a Share button to the laravel error page. This
| button allows you to easily share errors with colleagues or friends. It
| is enabled by default, but you can disable it here.
|
*/
'sender' => [
'class' => \Spatie\LaravelFlare\Senders\LaravelHttpSender::class,
'config' => [
'timeout' => 10,
],
],
'enable_share_button' => true,
/*
|--------------------------------------------------------------------------
@ -168,7 +154,7 @@ return [
|
*/
'trace' => env('FLARE_TRACE', false),
'trace' => env('FLARE_TRACE', true),
/*
|--------------------------------------------------------------------------
@ -181,26 +167,35 @@ return [
| which means that 10% of the traces will be recorded.
|
*/
'sampler' => [
'class' => \Spatie\FlareClient\Sampling\RateSampler::class,
'class' => RateSampler::class,
'config' => [
'rate' => 0.1,
'rate' => env('FLARE_SAMPLER_RATE', 0.1),
],
],
/*
|--------------------------------------------------------------------------
| Trace limits
| Log
|--------------------------------------------------------------------------
|
| Limits for the tracing data. These limits are used to prevent
| the tracing data from growing too large.
| Logging show you an overview of log entries within your application.
|
*/
'trace_limits' => [
'max_spans' => 512,
'max_attributes_per_span' => 128,
'max_span_events_per_span' => 128,
'max_attributes_per_span_event' => 128,
],
'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,
];

View file

@ -1,22 +0,0 @@
<?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(',', env('LOG_STACK', 'single')),
'channels' => explode(',', (string) 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', 'Laravel Log'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'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,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],

View file

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

View file

@ -24,7 +24,8 @@ 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", "null"
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
@ -72,6 +73,22 @@ return [
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*

View file

@ -1,67 +0,0 @@
<?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,7 +15,11 @@ return [
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
@ -35,4 +39,8 @@ 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", "apc",
| "memcached", "redis", "dynamodb", "array"
| Supported: "file", "cookie", "database", "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: "apc", "dynamodb", "memcached", "redis"
| Affects: "dynamodb", "memcached", "redis"
|
*/
@ -125,12 +125,11 @@ 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(env('APP_NAME', 'laravel'), '_').'_session'
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
@ -153,7 +152,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 and all subdomains. Typically, this shouldn't be changed.
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
@ -215,4 +214,20 @@ 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',
];

View file

@ -1,18 +0,0 @@
<?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 \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article>
* @extends Factory<Article>
*/
class ArticleFactory extends Factory
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,26 @@
<?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 character varying(255) NOT NULL,
target character varying(255) NOT NULL,
source text NOT NULL,
target text NOT NULL,
commentable_id integer,
commentable_type character varying(255),
type character varying(255),

View file

@ -4,6 +4,9 @@ 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;
@ -19,9 +22,9 @@ class LikesTableSeeder extends Seeder
$now = Carbon::now()->subDays(rand(3, 6));
$faker = new Generator;
$faker->addProvider(new \Faker\Provider\en_US\Person($faker));
$faker->addProvider(new \Faker\Provider\Lorem($faker));
$faker->addProvider(new \Faker\Provider\Internet($faker));
$faker->addProvider(new Person($faker));
$faker->addProvider(new Lorem($faker));
$faker->addProvider(new Internet($faker));
$likeFromAuthor = Like::create([
'url' => $faker->url,
'author_url' => $faker->url,

View file

@ -84,7 +84,9 @@ class NotesTableSeeder extends Seeder
// copy aarons profile pic in place
$spl = new SplFileInfo(public_path().'/assets/profile-images/aaronparecki.com');
if ($spl->isDir() === false) {
mkdir(public_path() . '/assets/profile-images/aaronparecki.com', 0755);
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');
}
@ -171,7 +173,9 @@ class NotesTableSeeder extends Seeder
$noteWithOnlyImage->setCreatedAt($now);
$noteWithOnlyImage->setUpdatedAt($now);
$noteWithOnlyImage->save();
$noteWithOnlyImage->media()->save($media);
$noteWithOnlyImage->media()->attach($media->id, [
'alt_text' => 'Test alt text',
]);
DB::table('notes')
->where('id', $noteWithOnlyImage->id)
->update(['updated_at' => $now->toDateTimeString()]);

View file

@ -2,11 +2,11 @@
services:
laravel.test:
build:
context: ./vendor/laravel/sail/runtimes/8.4
context: ./vendor/laravel/sail/runtimes/8.5
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
image: sail-8.4/app
image: sail-8.5/app
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:

BIN
jbuk_dev_backup.dump Normal file

Binary file not shown.

1545
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,15 +6,15 @@
"repository": "https://github.com/jonnybarnes/jonnybarnes.uk",
"license": "CC0-1.0",
"devDependencies": {
"@eslint/js": "^9.6.0",
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.1.0",
"esbuild": "^0.27.1",
"eslint": "^9.7.0",
"globals": "^16.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.0.1",
"globals": "^17.3.0",
"lightningcss": "^1.29.3",
"lightningcss-cli": "^1.29.3",
"stylelint": "^16.7.0",
"stylelint-config-standard": "^39.0.0"
"stylelint": "^17.3.0",
"stylelint-config-standard": "^40.0.0"
},
"scripts": {
"eslint": "eslint resources/js/*.js",
@ -30,5 +30,9 @@
"dependencies": {
"@11ty/is-land": "^5.0.0",
"@zachleat/snow-fall": "^1.0.3"
},
"allowScripts": {
"esbuild@0.27.7": true,
"lightningcss-cli@1.32.0": true
}
}

View file

@ -1,6 +1,3 @@
{
"preset": "laravel",
"rules": {
"concat_space": false
}
"preset": "laravel"
}

View file

@ -1,2 +1,2 @@
@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,95vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.h-feed{& .note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content>p:first-child{margin-block-start:0}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}& .reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform,opacity,display allow-discrete,overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}}
@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}}
/*# sourceMappingURL=/assets/css/app.css.map */

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -0,0 +1,87 @@
pre, code {
color: #d8dee9;
background-color: #2e3440;
}
.hl-keyword {
color: #81A1C1;
}
.hl-property {
color: #88C0D0;
}
.hl-attribute {
font-style: italic;
}
.hl-type {
color: #8FBCBB;
}
.hl-generic {
color: #81A1C1;
}
.hl-value {
color: #A3BE8C;
}
.hl-literal {
color: #A3BE8C;
}
.hl-number {
color: #B48EAD;
}
.hl-variable {
color: #D8DEE9;
}
.hl-comment {
color: #616E88;
}
.hl-blur {
filter: blur(2px);
}
.hl-strong {
font-weight: bold;
}
.hl-em {
font-style: italic;
}
.hl-addition {
display: inline-block;
min-width: 100%;
background-color: #00FF0022;
}
.hl-deletion {
display: inline-block;
min-width: 100%;
background-color: #FF000011;
}
.hl-gutter {
display: inline-block;
font-size: 0.9em;
color: #555;
padding: 0 1ch;
margin-right: 1ch;
user-select: none;
}
.hl-gutter-addition {
background-color: #34A853;
color: #fff;
}
.hl-gutter-deletion {
background-color: #EA4334;
color: #fff;
}

Binary file not shown.

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