Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb35a0aa2d |
|||
|
6727138f43 |
|||
|
4aa93d63bb |
|||
|
77998a963e |
|||
|
568ae78864 |
|||
|
9e9fbdc127 |
|||
|
9532aae37e |
|||
|
e9d8f4c74b |
|||
|
be2bc267ad |
|||
|
a76c84b40f |
|||
|
99f306ede1 |
|||
|
714cd95d79 |
|||
|
6a0bb623fc |
|||
|
30ec2ec0e8 |
|||
|
4ed98c30b2 |
|||
|
faf8e5c1ec |
|||
|
24da24a677 |
|||
|
9d6cf6c815 |
|||
|
9c9a6392c8 |
|||
|
d5706b5f8f |
|||
|
f9f2744fad |
|||
|
31c49ac3fc |
|||
|
287520ad7b |
|||
|
2eab5749ea |
|||
|
e08763c526 |
103 changed files with 3139 additions and 1995 deletions
|
|
@ -78,6 +78,8 @@ SESSION_SAME_SITE=strict
|
|||
|
||||
LOG_SLACK_WEBHOOK_URL=
|
||||
|
||||
BRRR_WEBHOOK_URL=
|
||||
|
||||
FLARE_KEY=
|
||||
|
||||
IGNITION_OPEN_AI_KEY=
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ 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;
|
||||
use Intervention\Image\Exceptions\DecoderException;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ReprocessMediaImages extends Command
|
||||
{
|
||||
|
|
@ -16,7 +16,7 @@ class ReprocessMediaImages extends Command
|
|||
|
||||
protected $description = 'Regenerate medium and small image variants using correct aspect-ratio scaling';
|
||||
|
||||
public function handle(ImageManager $manager): void
|
||||
public function handle(): void
|
||||
{
|
||||
$media = Media::where('type', 'image')
|
||||
->whereNotNull('image_widths')
|
||||
|
|
@ -44,10 +44,10 @@ class ReprocessMediaImages extends Command
|
|||
|
||||
$this->info("Processing: {$path}");
|
||||
|
||||
$image = Image::fromStorage($path, 'public');
|
||||
try {
|
||||
$file = Storage::disk('public')->get($path);
|
||||
$image = $manager->read($file);
|
||||
} catch (DecoderException) {
|
||||
$image->width();
|
||||
} catch (ImageException) {
|
||||
$this->warn(' Could not decode image, skipping.');
|
||||
|
||||
continue;
|
||||
|
|
@ -57,11 +57,8 @@ class ReprocessMediaImages extends Command
|
|||
$extension = array_pop($filenameParts);
|
||||
$basename = trim(implode('.', $filenameParts), '.');
|
||||
|
||||
$medium = $image->scale(width: 1000);
|
||||
Storage::disk('public')->put($basename.'-medium.'.$extension, (string) $medium->encode());
|
||||
|
||||
$small = $image->scale(width: 500);
|
||||
Storage::disk('public')->put($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());
|
||||
|
||||
$this->info(' Done.');
|
||||
}
|
||||
|
|
|
|||
16
app/Http/Controllers/AboutPageController.php
Normal file
16
app/Http/Controllers/AboutPageController.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\About;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AboutPageController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
return view('about', [
|
||||
'about' => About::first()?->content,
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/Http/Controllers/Admin/AboutController.php
Normal file
32
app/Http/Controllers/Admin/AboutController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\About;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AboutController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
$about = About::first();
|
||||
|
||||
return view('admin.about.show', [
|
||||
'aboutEntry' => $about,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$about = About::firstOrNew();
|
||||
$about->content = $request->input('content');
|
||||
$about->save();
|
||||
|
||||
return redirect()->route('admin.about.show');
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,11 @@ namespace App\Http\Controllers\Admin;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Contact;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\BadResponseException;
|
||||
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
|
||||
|
|
@ -113,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 (BadResponseException $e) {
|
||||
$response = Http::throw()->get($contact->homepage);
|
||||
} catch (RequestException $e) {
|
||||
return redirect('/admin/contacts/'.$contactId.'/edit')
|
||||
->with('error', 'Bad resposne from contact’s 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');
|
||||
|
|
@ -129,8 +128,8 @@ class ContactsController extends Controller
|
|||
}
|
||||
if ($avatarURL !== null) {
|
||||
try {
|
||||
$avatar = $client->get($avatarURL);
|
||||
} catch (BadResponseException $e) {
|
||||
$avatar = Http::throw()->get($avatarURL);
|
||||
} catch (RequestException $e) {
|
||||
return redirect('/admin/contacts/'.$contactId.'/edit')
|
||||
->with('error', 'Unable to download avatar');
|
||||
}
|
||||
|
|
@ -141,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),
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ class PasskeysController extends Controller
|
|||
], 400);
|
||||
}
|
||||
|
||||
$passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id);
|
||||
$passkey = Passkey::firstWhere('passkey_id', Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId));
|
||||
if (! $passkey) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
|
|
|
|||
32
app/Http/Controllers/Admin/SettingsController.php
Normal file
32
app/Http/Controllers/Admin/SettingsController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
$settings = Setting::first();
|
||||
|
||||
return view('admin.settings.show', [
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$settings = Setting::firstOrNew();
|
||||
$settings->winter_effect_enabled = $request->boolean('winter_effect_enabled');
|
||||
$settings->save();
|
||||
|
||||
return redirect()->route('admin.settings.show');
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Admin/TokensController.php
Normal file
65
app/Http/Controllers/Admin/TokensController.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MicropubToken;
|
||||
use App\Services\TokenService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TokensController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show a list of issued Micropub tokens.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$tokens = MicropubToken::latest()->get();
|
||||
|
||||
return view('admin.tokens.index', compact('tokens'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form to manually generate a new Micropub token.
|
||||
*
|
||||
* This is for clients (e.g. iA Writer) that don't support the IndieAuth
|
||||
* PKCE flow and instead expect to be given a token directly.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.tokens.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually generate a new Micropub token.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'client_id' => 'required|string',
|
||||
'scope' => 'required|array|min:1',
|
||||
]);
|
||||
|
||||
$token = resolve(TokenService::class)->getNewToken([
|
||||
'me' => config('app.url'),
|
||||
'client_id' => $validated['client_id'],
|
||||
'scope' => implode(' ', $validated['scope']),
|
||||
]);
|
||||
|
||||
return redirect('/admin/tokens')->with('new_token', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a Micropub token.
|
||||
*/
|
||||
public function revoke(MicropubToken $token): RedirectResponse
|
||||
{
|
||||
$token->revoke();
|
||||
|
||||
return redirect('/admin/tokens');
|
||||
}
|
||||
}
|
||||
|
|
@ -7,66 +7,13 @@ namespace App\Http\Controllers;
|
|||
use App\Models\Article;
|
||||
use App\Models\Note;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class FeedsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Returns the blog RSS feed.
|
||||
*/
|
||||
public function blogRss(): Response
|
||||
{
|
||||
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
|
||||
$buildDate = $articles->first()->updated_at->toRssString();
|
||||
|
||||
return response()
|
||||
->view('articles.rss', compact('articles', 'buildDate'))
|
||||
->header('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blog Atom feed.
|
||||
*/
|
||||
public function blogAtom(): Response
|
||||
{
|
||||
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
|
||||
|
||||
return response()
|
||||
->view('articles.atom', compact('articles'))
|
||||
->header('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notes RSS feed.
|
||||
*/
|
||||
public function notesRss(): Response
|
||||
{
|
||||
$notes = Note::latest()->take(20)->get();
|
||||
$buildDate = $notes->first()->updated_at->toRssString();
|
||||
|
||||
return response()
|
||||
->view('notes.rss', compact('notes', 'buildDate'))
|
||||
->header('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notes Atom feed.
|
||||
*/
|
||||
public function notesAtom(): Response
|
||||
{
|
||||
$notes = Note::latest()->take(20)->get();
|
||||
|
||||
return response()
|
||||
->view('notes.atom', compact('notes'))
|
||||
->header('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||
}
|
||||
|
||||
/** @todo sort out return type for json responses */
|
||||
|
||||
/**
|
||||
* Returns the blog JSON feed.
|
||||
*/
|
||||
public function blogJson(): array
|
||||
public function blogJson(): JsonResponse
|
||||
{
|
||||
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
|
||||
$data = [
|
||||
|
|
@ -94,13 +41,15 @@ class FeedsController extends Controller
|
|||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
return response()->json($data, 200, [
|
||||
'Content-Type' => 'application/feed+json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notes JSON feed.
|
||||
*/
|
||||
public function notesJson(): array
|
||||
public function notesJson(): JsonResponse
|
||||
{
|
||||
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
|
||||
$data = [
|
||||
|
|
@ -130,7 +79,9 @@ class FeedsController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
return response()->json($data, 200, [
|
||||
'Content-Type' => 'application/feed+json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MicropubToken;
|
||||
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;
|
||||
|
|
@ -25,9 +25,10 @@ class IndieAuthController extends Controller
|
|||
'issuer' => config('app.url'),
|
||||
'authorization_endpoint' => route('indieauth.start'),
|
||||
'token_endpoint' => route('indieauth.token'),
|
||||
'revocation_endpoint' => route('indieauth.revocation'),
|
||||
'introspection_endpoint' => route('indieauth.introspection'),
|
||||
'introspection_endpoint_auth_methods_supported' => ['Bearer'],
|
||||
'code_challenge_methods_supported' => ['S256'],
|
||||
// 'introspection_endpoint' => route('indieauth.introspection'),
|
||||
// 'introspection_endpoint_auth_methods_supported' => ['none'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +180,50 @@ class IndieAuthController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a POST request to the IndieAuth revocation endpoint (RFC 7009).
|
||||
*
|
||||
* Per spec this always returns HTTP 200, whether the token was revoked,
|
||||
* unknown, or already revoked, so callers can't probe token validity.
|
||||
*/
|
||||
public function processRevocationRequest(Request $request): JsonResponse
|
||||
{
|
||||
MicropubToken::findActive($request->get('token'))?->revoke();
|
||||
|
||||
return response()->json([], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a POST request to the IndieAuth token introspection endpoint
|
||||
* (RFC 7662, extended by IndieAuth to require the `me` property).
|
||||
*
|
||||
* The caller must itself present a currently-active token as a Bearer
|
||||
* credential to use this endpoint, per spec ("MUST also require some
|
||||
* form of authorization"). Per spec, an inactive token being introspected
|
||||
* still gets a 200 response containing only `active: false` - no other
|
||||
* information about why it's inactive is given.
|
||||
*/
|
||||
public function processIntrospectionRequest(Request $request): JsonResponse
|
||||
{
|
||||
if (! MicropubToken::findActive($request->bearerToken())) {
|
||||
return response()->json([], 401);
|
||||
}
|
||||
|
||||
$token = MicropubToken::findActive($request->get('token'));
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['active' => false]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'active' => true,
|
||||
'me' => $token->me,
|
||||
'client_id' => $token->client_id,
|
||||
'scope' => $token->scope,
|
||||
'iat' => $token->created_at->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function isValidRedirectUri(string $clientId, string $redirectUri): bool
|
||||
{
|
||||
// If client_id is not a valid URL, then it's not valid
|
||||
|
|
@ -199,15 +244,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'] ?? [];
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ use App\Services\Micropub\MicropubHandlerRegistry;
|
|||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Lcobucci\JWT\Token;
|
||||
|
||||
class MicropubController extends Controller
|
||||
{
|
||||
|
|
@ -71,7 +70,9 @@ class MicropubController extends Controller
|
|||
'error' => 'invalid_request',
|
||||
'error_description' => 'No known note with given ID',
|
||||
], 404);
|
||||
} catch (MicropubUnsupportedModelException) {
|
||||
} catch (MicropubUnsupportedModelException $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'invalid',
|
||||
'error_description' => 'This implementation currently only supports the updating of notes',
|
||||
|
|
@ -81,12 +82,16 @@ class MicropubController extends Controller
|
|||
'error' => 'invalid_request',
|
||||
'error_description' => $e->getMessage(),
|
||||
], 400);
|
||||
} catch (MicropubHandlerException) {
|
||||
} catch (MicropubHandlerException $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'unsupported_operation',
|
||||
'error_description' => 'The request could not be processed by this server',
|
||||
], 500);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'server_error',
|
||||
'error_description' => 'An error occurred processing the request',
|
||||
|
|
@ -135,7 +140,7 @@ class MicropubController extends Controller
|
|||
}
|
||||
|
||||
// the default response is just to return the token data
|
||||
/** @var Token $tokenData */
|
||||
/** @var array $tokenData */
|
||||
$tokenData = $request->input('token_data');
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -25,9 +26,7 @@ class MicropubMediaController extends Controller
|
|||
$tokenData = $request->input('token_data');
|
||||
|
||||
$scopes = $tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
if (! in_array('create', $scopes, true)) {
|
||||
return (new MicropubResponses)->insufficientScopeResponse();
|
||||
}
|
||||
|
|
@ -83,9 +82,7 @@ class MicropubMediaController extends Controller
|
|||
$tokenData = $request->input('token_data');
|
||||
|
||||
$scopes = $tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
if (! in_array('create', $scopes, true)) {
|
||||
return (new MicropubResponses)->insufficientScopeResponse();
|
||||
}
|
||||
|
|
@ -111,12 +108,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ class LinkHeadersMiddleware
|
|||
$response->header('Link', '<'.route('indieauth.metadata').'>; rel="indieauth-metadata"', false);
|
||||
$response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', false);
|
||||
$response->header('Link', '<'.route('indieauth.token').'>; rel="token_endpoint"', false);
|
||||
$response->header('Link', '<'.route('indieauth.revocation').'>; rel="revocation_endpoint"', false);
|
||||
$response->header('Link', '<'.route('indieauth.introspection').'>; rel="introspection_endpoint"', false);
|
||||
$response->header('Link', '<'.route('micropub-endpoint').'>; rel="micropub"', false);
|
||||
$response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,9 @@ declare(strict_types=1);
|
|||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Http\Responses\MicropubResponses;
|
||||
use App\Models\MicropubToken;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Encoding\CannotDecodeContent;
|
||||
use Lcobucci\JWT\Token;
|
||||
use Lcobucci\JWT\Token\InvalidTokenStructure;
|
||||
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class VerifyMicropubToken
|
||||
|
|
@ -39,15 +35,15 @@ class VerifyMicropubToken
|
|||
], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$tokenData = $this->validateToken($rawToken);
|
||||
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
|
||||
$token = MicropubToken::findActive($rawToken);
|
||||
|
||||
if (! $token) {
|
||||
$micropubResponses = new MicropubResponses;
|
||||
|
||||
return $micropubResponses->invalidTokenResponse();
|
||||
}
|
||||
|
||||
if ($tokenData->claims()->has('scope') === false) {
|
||||
if ($token->scope === '') {
|
||||
$micropubResponses = new MicropubResponses;
|
||||
|
||||
return $micropubResponses->tokenHasNoScopeResponse();
|
||||
|
|
@ -56,26 +52,10 @@ class VerifyMicropubToken
|
|||
return $next($request->merge([
|
||||
'access_token' => $rawToken,
|
||||
'token_data' => [
|
||||
'me' => $tokenData->claims()->get('me'),
|
||||
'scope' => $tokenData->claims()->get('scope'),
|
||||
'client_id' => $tokenData->claims()->get('client_id'),
|
||||
'me' => $token->me,
|
||||
'scope' => $token->scope,
|
||||
'client_id' => $token->client_id,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the token signature is valid.
|
||||
*/
|
||||
private function validateToken(string $bearerToken): Token
|
||||
{
|
||||
$config = resolve(Configuration::class);
|
||||
|
||||
$token = $config->parser()->parse($bearerToken);
|
||||
|
||||
$constraints = $config->validationConstraints();
|
||||
|
||||
$config->validator()->assert($token, ...$constraints);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
56
app/Jobs/NotifyBrrrOfWebMention.php
Normal file
56
app/Jobs/NotifyBrrrOfWebMention.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,13 @@ declare(strict_types=1);
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Like;
|
||||
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\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
|
||||
|
||||
|
|
@ -32,13 +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
|
||||
{
|
||||
$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'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 +30,16 @@ class ProcessMedia implements ShouldQueue
|
|||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(ImageManager $manager): void
|
||||
public function handle(): void
|
||||
{
|
||||
// Load file
|
||||
$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($this->filename);
|
||||
|
||||
|
|
@ -49,18 +50,15 @@ class ProcessMedia implements ShouldQueue
|
|||
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->scale(width: 1000);
|
||||
Storage::disk('public')->put($basename.'-medium.'.$extension, (string) $medium->encode());
|
||||
|
||||
$small = $image->scale(width: 500);
|
||||
Storage::disk('public')->put($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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +12,7 @@ use Illuminate\Bus\Queueable;
|
|||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Mf2\Parser;
|
||||
|
||||
|
|
@ -32,8 +31,6 @@ class SendWebMentions implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
|
|
@ -43,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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,8 +50,6 @@ class SendWebMentions implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Discover if a URL has a webmention endpoint.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function discoverWebmentionEndpoint(string $url): ?string
|
||||
{
|
||||
|
|
@ -71,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);
|
||||
|
|
@ -82,7 +73,7 @@ class SendWebMentions implements ShouldQueue
|
|||
}
|
||||
|
||||
// failed to find a header so parse HTML
|
||||
$html = (string) $response->getBody();
|
||||
$html = $response->body();
|
||||
|
||||
if ($html === '') {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ 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
|
||||
{
|
||||
|
|
@ -31,29 +30,18 @@ class SyndicateNoteToBluesky implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle(Client $guzzle): void
|
||||
public function handle(): void
|
||||
{
|
||||
$response = $guzzle->request(
|
||||
'POST',
|
||||
'https://brid.gy/publish/webmention',
|
||||
[
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'form_params' => [
|
||||
// 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',
|
||||
],
|
||||
'http_errors' => false,
|
||||
]
|
||||
);
|
||||
]);
|
||||
|
||||
$body = json_decode((string) $response->getBody(), true);
|
||||
$body = $response->json();
|
||||
|
||||
if ($response->getStatusCode() === 201) {
|
||||
if ($response->status() === 201) {
|
||||
$this->note->bluesky_url = $body['url'];
|
||||
$this->note->save();
|
||||
|
||||
|
|
@ -61,7 +49,7 @@ class SyndicateNoteToBluesky implements ShouldQueue
|
|||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Bridgy publish to Bluesky failed: '.($body['error'] ?? (string) $response->getBody())
|
||||
'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ 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
|
||||
{
|
||||
|
|
@ -31,29 +30,18 @@ class SyndicateNoteToMastodon implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle(Client $guzzle): void
|
||||
public function handle(): void
|
||||
{
|
||||
$response = $guzzle->request(
|
||||
'POST',
|
||||
'https://brid.gy/publish/webmention',
|
||||
[
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'form_params' => [
|
||||
// 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',
|
||||
],
|
||||
'http_errors' => false,
|
||||
]
|
||||
);
|
||||
]);
|
||||
|
||||
$body = json_decode((string) $response->getBody(), true);
|
||||
$body = $response->json();
|
||||
|
||||
if ($response->getStatusCode() === 201) {
|
||||
if ($response->status() === 201) {
|
||||
$this->note->mastodon_url = $body['url'];
|
||||
$this->note->save();
|
||||
|
||||
|
|
@ -61,7 +49,7 @@ class SyndicateNoteToMastodon implements ShouldQueue
|
|||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Bridgy publish to Mastodon failed: '.($body['error'] ?? (string) $response->getBody())
|
||||
'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
app/Models/About.php
Normal file
13
app/Models/About.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class About extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'about';
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ class Article extends Model
|
|||
return [
|
||||
'titleurl' => [
|
||||
'source' => 'title',
|
||||
'includeTrashed' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
@ -93,6 +94,13 @@ class Article extends Model
|
|||
);
|
||||
}
|
||||
|
||||
protected function uri(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
get: fn () => config('app.url').$this->link,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include articles from a particular year/month.
|
||||
*/
|
||||
|
|
|
|||
50
app/Models/MicropubToken.php
Normal file
50
app/Models/MicropubToken.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['token_hash', 'client_id', 'me', 'scope'])]
|
||||
class MicropubToken extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function revoke(): void
|
||||
{
|
||||
$this->forceFill(['revoked_at' => now()])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the active (non-revoked) token matching a raw bearer token value.
|
||||
*
|
||||
* Accepts mixed because callers pass request input directly, which PHP
|
||||
* lets be an array (e.g. a client sending token[]=a) - casting that to
|
||||
* string would throw, so anything non-string is just treated as absent.
|
||||
*/
|
||||
public static function findActive(mixed $rawToken): ?self
|
||||
{
|
||||
if (! is_string($rawToken) || $rawToken === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::where('token_hash', hash('sha256', $rawToken))
|
||||
->whereNull('revoked_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
protected function isRevoked(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->revoked_at !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ namespace App\Models;
|
|||
use App\CommonMark\Generators\MentionGenerator;
|
||||
use App\CommonMark\Renderers\MentionRenderer;
|
||||
use App\Observers\NoteObserver;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
|
|
@ -20,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||
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;
|
||||
|
|
@ -363,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)) {
|
||||
|
|
|
|||
18
app/Models/Setting.php
Normal file
18
app/Models/Setting.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'winter_effect_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
|
@ -2,19 +2,13 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Validation\Constraint\SignedWith;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||
|
||||
|
|
@ -33,11 +27,6 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// configure Intervention/Image
|
||||
$this->app->bind('Intervention\Image\ImageManager', function () {
|
||||
return ImageManager::withDriver(config('image.driver'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Paginate a standard Laravel Collection.
|
||||
*
|
||||
|
|
@ -62,17 +51,6 @@ class AppServiceProvider extends ServiceProvider
|
|||
);
|
||||
});
|
||||
|
||||
// Configure JWT builder
|
||||
$this->app->bind('Lcobucci\JWT\Configuration', function () {
|
||||
$key = InMemory::plainText(config('app.key'));
|
||||
|
||||
$config = Configuration::forSymmetricSigner(new Sha256, $key);
|
||||
|
||||
$config->setValidationConstraints(new SignedWith(new Sha256, $key));
|
||||
|
||||
return $config;
|
||||
});
|
||||
|
||||
// Configure HtmlSanitizer
|
||||
$this->app->bind(HtmlSanitizer::class, function () {
|
||||
return new HtmlSanitizer(
|
||||
|
|
@ -82,39 +60,15 @@ class AppServiceProvider extends ServiceProvider
|
|||
);
|
||||
});
|
||||
|
||||
// Configure Guzzle
|
||||
$this->app->bind('RetryGuzzle', function () {
|
||||
$handlerStack = 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 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());
|
||||
|
||||
// Share whether the winter snow effect is enabled with the base layout
|
||||
View::composer('master', function ($view) {
|
||||
$view->with('winterEffectEnabled', Setting::first()?->winter_effect_enabled ?? false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,29 @@ use App\Models\Article;
|
|||
|
||||
class ArticleService
|
||||
{
|
||||
/**
|
||||
* @throws \InvalidArgumentException if a published article already has this title
|
||||
*/
|
||||
public function create(array $data): Article
|
||||
{
|
||||
return Article::create([
|
||||
$attributes = [
|
||||
'title' => $data['name'],
|
||||
'main' => $data['content'],
|
||||
'published' => true,
|
||||
]);
|
||||
'published' => ($data['post-status'] ?? null) !== 'draft',
|
||||
];
|
||||
|
||||
$existing = Article::where('title', $data['name'])->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
if ($existing->published) {
|
||||
throw new \InvalidArgumentException("An article titled \"{$data['name']}\" has already been published");
|
||||
}
|
||||
|
||||
$existing->update($attributes);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return Article::create($attributes);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class EntryData extends MicropubData
|
|||
public readonly mixed $checkin,
|
||||
public readonly mixed $syndication,
|
||||
public readonly ?array $photos,
|
||||
public readonly ?string $postStatus,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(Request $request): static
|
||||
|
|
@ -49,6 +50,7 @@ class EntryData extends MicropubData
|
|||
checkin: Arr::get($data, 'properties.checkin.0'),
|
||||
syndication: Arr::get($data, 'properties.syndication.0'),
|
||||
photos: Arr::get($data, 'properties.photo'),
|
||||
postStatus: Arr::get($data, 'properties.post-status.0'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +69,7 @@ class EntryData extends MicropubData
|
|||
checkin: $request->input('checkin'),
|
||||
syndication: $request->input('syndication'),
|
||||
photos: $request->input('photos'),
|
||||
postStatus: $request->input('post-status'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +103,7 @@ class EntryData extends MicropubData
|
|||
checkin: $data['checkin'] ?? null,
|
||||
syndication: $data['syndication'] ?? null,
|
||||
photos: $data['photos'] ?? null,
|
||||
postStatus: $data['post-status'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +124,7 @@ class EntryData extends MicropubData
|
|||
'checkin' => $this->checkin,
|
||||
'syndication' => $this->syndication,
|
||||
'photos' => $this->photos,
|
||||
'post-status' => $this->postStatus,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ class CardHandler implements MicropubHandlerInterface
|
|||
assert($data instanceof CardData);
|
||||
|
||||
$scopes = $data->tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
|
||||
if (! in_array('create', $scopes, true)) {
|
||||
throw new InvalidTokenScopeException;
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ class EntryHandler implements MicropubHandlerInterface
|
|||
assert($data instanceof EntryData);
|
||||
|
||||
$scopes = $data->tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
|
||||
if (! in_array('create', $scopes, true)) {
|
||||
throw new InvalidTokenScopeException;
|
||||
|
|
@ -39,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
|
|||
$location = match (true) {
|
||||
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
|
||||
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
|
||||
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link,
|
||||
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
|
||||
default => resolve(NoteService::class)->create($dataArray)->uri,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
assert($data instanceof UpdateData);
|
||||
|
||||
$scopes = $data->tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
|
||||
if (! in_array('update', $scopes, true)) {
|
||||
throw new InvalidTokenScopeException;
|
||||
|
|
|
|||
|
|
@ -5,28 +5,26 @@ declare(strict_types=1);
|
|||
namespace App\Services;
|
||||
|
||||
use App\Jobs\AddClientToDatabase;
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use App\Models\MicropubToken;
|
||||
|
||||
class TokenService
|
||||
{
|
||||
/**
|
||||
* Generate a JWT token.
|
||||
* Generate a new bearer token.
|
||||
*/
|
||||
public function getNewToken(array $data): string
|
||||
{
|
||||
$config = resolve(Configuration::class);
|
||||
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
|
||||
$token = $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', $data['client_id'])
|
||||
->withClaim('me', $data['me'])
|
||||
->withClaim('scope', $data['scope'])
|
||||
->withClaim('nonce', bin2hex(random_bytes(8)))
|
||||
->getToken($config->signer(), $config->signingKey());
|
||||
MicropubToken::create([
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'client_id' => $data['client_id'],
|
||||
'me' => $data['me'],
|
||||
'scope' => $data['scope'],
|
||||
]);
|
||||
|
||||
dispatch(new AddClientToDatabase($data['client_id']));
|
||||
|
||||
return $token->toString();
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
except: [
|
||||
'auth', // This is the IndieAuth auth endpoint
|
||||
'token', // This is the IndieAuth token endpoint
|
||||
'revocation', // This is the IndieAuth revocation endpoint
|
||||
'introspect', // This is the IndieAuth introspection endpoint
|
||||
'api/post',
|
||||
'api/media',
|
||||
'micropub/places',
|
||||
|
|
|
|||
|
|
@ -14,20 +14,18 @@
|
|||
"ext-pgsql": "*",
|
||||
"ext-sodium": "*",
|
||||
"cviebrock/eloquent-sluggable": "^13.0",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"indieauth/client": "^1.1",
|
||||
"intervention/image": "^3",
|
||||
"intervention/image": "^4.0",
|
||||
"jonnybarnes/indieweb": "~0.2",
|
||||
"jonnybarnes/webmentions-parser": "~0.5",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/horizon": "^5.0",
|
||||
"laravel/scout": "^10.1",
|
||||
"laravel/tinker": "^3.0",
|
||||
"lcobucci/jwt": "^5.0",
|
||||
"league/commonmark": "^2.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"mf2/mf2": "~0.3",
|
||||
"spatie/laravel-flare": "^2.2",
|
||||
"spatie/laravel-flare": "^3.0",
|
||||
"symfony/html-sanitizer": "^8.0",
|
||||
"tempest/highlight": "^2.27",
|
||||
"web-auth/webauthn-lib": "^5.0"
|
||||
|
|
@ -42,8 +40,8 @@
|
|||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/php-code-coverage": "^12.0",
|
||||
"phpunit/phpunit": "^12.5.12",
|
||||
"phpunit/php-code-coverage": "^14.0",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"spatie/laravel-ray": "^1.12",
|
||||
"spatie/x-ray": "^1.2"
|
||||
},
|
||||
|
|
|
|||
1349
composer.lock
generated
1349
composer.lock
generated
File diff suppressed because it is too large
Load diff
125
config/flare.php
125
config/flare.php
|
|
@ -1,8 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Spatie\FlareClient\Api;
|
||||
use Spatie\FlareClient\Sampling\RateSampler;
|
||||
use Spatie\LaravelFlare\AttributesProviders\LaravelUserAttributesProvider;
|
||||
use Spatie\LaravelFlare\FlareConfig;
|
||||
use Spatie\LaravelFlare\Senders\LaravelHttpSender;
|
||||
|
||||
|
|
@ -21,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
|
||||
|
|
@ -47,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
|
||||
|
|
@ -88,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),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -113,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
|
||||
|
|
@ -145,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' => LaravelHttpSender::class,
|
||||
'config' => [
|
||||
'timeout' => 10,
|
||||
],
|
||||
],
|
||||
'enable_share_button' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -170,7 +154,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'trace' => env('FLARE_TRACE', false),
|
||||
'trace' => env('FLARE_TRACE', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -183,26 +167,35 @@ return [
|
|||
| which means that 10% of the traces will be recorded.
|
||||
|
|
||||
*/
|
||||
|
||||
'sampler' => [
|
||||
'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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
];
|
||||
|
|
@ -39,4 +39,8 @@ return [
|
|||
'token' => env('CLOUDCONVERT_API_TOKEN'),
|
||||
],
|
||||
|
||||
'brrr' => [
|
||||
'webhook_url' => env('BRRR_WEBHOOK_URL'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
24
database/factories/AboutFactory.php
Normal file
24
database/factories/AboutFactory.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\About;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<About>
|
||||
*/
|
||||
class AboutFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'content' => $this->faker->paragraph,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
database/factories/SettingFactory.php
Normal file
24
database/factories/SettingFactory.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Setting>
|
||||
*/
|
||||
class SettingFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'winter_effect_enabled' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?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::create('micropub_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('token_hash', 64)->unique();
|
||||
$table->string('client_id');
|
||||
$table->string('me');
|
||||
$table->string('scope');
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('client_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('micropub_tokens');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?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('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->boolean('winter_effect_enabled')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
28
database/migrations/2026_08_28_142830_create_about_table.php
Normal file
28
database/migrations/2026_08_28_142830_create_about_table.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?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('about', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->text('content');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('about');
|
||||
}
|
||||
};
|
||||
314
package-lock.json
generated
314
package-lock.json
generated
|
|
@ -121,9 +121,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -168,9 +168,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
|
||||
"integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -237,9 +237,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/selector-resolve-nested": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz",
|
||||
"integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz",
|
||||
"integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -725,9 +725,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
|
||||
"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -782,9 +782,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
|
||||
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
|
||||
"integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -1019,9 +1019,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
|
||||
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
|
||||
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1135,16 +1135,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
|
|
@ -1418,9 +1418,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
|
||||
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
|
||||
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
|
@ -1430,7 +1430,7 @@
|
|||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.6.0",
|
||||
"@eslint/config-helpers": "^0.7.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.2",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
|
|
@ -1454,7 +1454,7 @@
|
|||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.4",
|
||||
"minimatch": "^10.2.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
|
|
@ -1655,9 +1655,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1749,9 +1749,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz",
|
||||
"integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -1836,9 +1836,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz",
|
||||
"integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==",
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz",
|
||||
"integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1857,9 +1857,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/globby/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
|
||||
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2137,9 +2137,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2153,23 +2153,23 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2188,9 +2188,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli/-/lightningcss-cli-1.32.0.tgz",
|
||||
"integrity": "sha512-IFb/ChmSEbeWU3xeRybR6WFlJXCvfDS84//PUzLrRACgvoWrwRJBmcPS9azSo7LMh5QqEuyKanPBByhKM5z01Q==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli/-/lightningcss-cli-1.33.0.tgz",
|
||||
"integrity": "sha512-/tBcBZlBFFxy1iYKDC/HSH/NEjv7Frq6dmDVKRbhGJQI/gFJIJ1/4ZXclxJkd0eEjBjDc5MY0YugHcvJVVGJVg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MPL-2.0",
|
||||
|
|
@ -2208,23 +2208,23 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-cli-android-arm64": "1.32.0",
|
||||
"lightningcss-cli-darwin-arm64": "1.32.0",
|
||||
"lightningcss-cli-darwin-x64": "1.32.0",
|
||||
"lightningcss-cli-freebsd-x64": "1.32.0",
|
||||
"lightningcss-cli-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-cli-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-cli-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-cli-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-cli-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-cli-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-cli-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-cli-android-arm64": "1.33.0",
|
||||
"lightningcss-cli-darwin-arm64": "1.33.0",
|
||||
"lightningcss-cli-darwin-x64": "1.33.0",
|
||||
"lightningcss-cli-freebsd-x64": "1.33.0",
|
||||
"lightningcss-cli-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-cli-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-cli-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-cli-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-cli-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-cli-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-cli-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-android-arm64/-/lightningcss-cli-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-4O3QY+VdgpBZLIq4crcKOEPVAXX0p7zDoykuTVstRtyolg9XU8CntdtbxcMPSC4SkzAuM/W4KsnPAzmv6Jb5LA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-android-arm64/-/lightningcss-cli-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-c0Xd7Gxaw3mNOyrb9ET1JH3JjuB69GY/6pHO4vgwEbIoHCXAt6DBg6kxr5t/oZL9LRxdnZCXF+o0Evz6kuR/xA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2243,9 +2243,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-arm64/-/lightningcss-cli-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-Xx+zeD7bDKJZwbd1N63TJfIUHEtYspf+tqObdnQEJEvZAwmGfA4iEGrkCRT8R57tDRBDSXg3XHMDDvo/cq7gBQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-arm64/-/lightningcss-cli-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-sso5hSFPis7ldw2FcBopsZviSVAXWRGX8ybUwMhQHssTlERenQJ88WihZs08tCdSnQULo2kunnrGhh4VjRw8Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2264,9 +2264,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-x64/-/lightningcss-cli-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-fYWANZ8RJDpI0tBcPQ7oBOYihfXmgDBHR4lZ6d4z7rcRLlZAOeI00mTO0IXAKfSm/UgnatjM4aBuNlLh8L9noA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-darwin-x64/-/lightningcss-cli-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-/aDYpMv2QKpJoSIwvwpIs4tIX9SCU894eospSm55FR5MxDZkJDy4fEX6elsXkHCBDqbAknb4qd2h/oDMLTC4Ew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2285,9 +2285,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-freebsd-x64/-/lightningcss-cli-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-TJm7z1Ghvo9FKAza2KvfkFgH/9rcV1xAhCYQZlLrV0CiuTZ17uzLobBWb9oelGWUG+wTnEs6XEl4h/ve61YySg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-freebsd-x64/-/lightningcss-cli-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-kK9u4IEAvt3+1m7lkloyfZNfXykXq2vCH7lgZ5aEqa8VuBZYqvcAIQ4Qt1593QJxbko9HEKUP0erdSxw7k7eFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2306,9 +2306,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm-gnueabihf/-/lightningcss-cli-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-/jS9L5p3eexs5QJvARiDGxibz1umKJmmtff86fWXl3r7RbEUFrMAD4q1WhAR7DurRFgc1YWld6r0mX1YHEYttA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm-gnueabihf/-/lightningcss-cli-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-4bHmUlYCb8WKdPKx2yhk8y/kD034JVuUatDMWav0uU/hEIYJrxPimWBJJ4w2lgpcWchVnk/Kuihs4vaLjO/ozw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -2327,9 +2327,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-gnu/-/lightningcss-cli-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-PSdjwcRtSrJpsaqnY3ebnCfDOJ5ePi8s/0ItL3CX1b1Eu0iy44xo7MjgES1ZOQ2ntOthPRqaGsbAiVBLbgFLYQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-gnu/-/lightningcss-cli-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-DkYmkBix3icAZKZsxGsjBAj59CIV0zflZtsT66lCPd2z3OqBLJlhKRrw4TlAW7mIkYeOEYWKVz/G00ZGXNse4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2351,9 +2351,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-musl/-/lightningcss-cli-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-t6DdpXFtEdonZHzRgH8cmqhC2o4tl0KrD33cDBBniJz5TmWS20MJxb4YEr4WqBLksqW8GE399HJo+g8/YVuKcA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-arm64-musl/-/lightningcss-cli-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-euNOhzo1ysRl719HfFzRe9r9usWO2Z/Nb8RnmqXxb/kvzOgAv2BcflpoTu6tWhSrbHX6YS/WP6mMFrmII6T7Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2375,9 +2375,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-gnu/-/lightningcss-cli-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-QMQllbHYkbkQ4N+v8OGExQlGHBc3YZIcKlVkYucQQj66thkFQsRjmv8p5q3iCB0inNsCoSZ8lBspugkU1iPlPg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-gnu/-/lightningcss-cli-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-9Q4DAglm17bLhA+HZPB+vxjrHXdhR/8U44zyc8MQNA5Rw+tisvcoR/lv4iuBUFn2qreYVRWQq0wP0rgdhTSxyQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2399,9 +2399,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-musl/-/lightningcss-cli-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-dMSWdk4kMAi5f+J1xetxRCDQOvPix2whT0UdjuwP8r/5Xcdl2SQU/c80MQzu1S82kVDEANs1vHVEHp/+26LUnA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-linux-x64-musl/-/lightningcss-cli-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-+EobHdqxQ21SUMF8OU0qcizNIrLpPDaPTKQCaGbra9gL3NrW3ELG+exZl5y+WzJsEjLZ0hhjbCFjHUL/03H2JA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2423,9 +2423,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-win32-arm64-msvc/-/lightningcss-cli-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-MJo22OqSp9FLV10nTRDJQhP6zkEBFqBFQe9mnjfCs+G1Ft+QimIPmC+gBqZHFXveOBOsjFLzU72q0FPvRWFmZQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-win32-arm64-msvc/-/lightningcss-cli-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-iwQ+8rHQy3ewC4c43Ik3UZ8TCC/rjShP3r/8TOPw4fT8wQmW7dS3BNNHPK+LWz9qNrObATJ4MZEEZVRneckOBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2444,9 +2444,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-cli-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-win32-x64-msvc/-/lightningcss-cli-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-nOBHLPeePXZpGR4Ptp+gkOIkr9pxlq2dFmO954ol5zBi/iOKxuD1hUTIQ7aG+ldKIx4eH9jwoTfZ6owUkm1paA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-cli-win32-x64-msvc/-/lightningcss-cli-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-sgtNPw2gxnY8OzCCMTKCkHSIiBYb/wfXLpLMmDIJFzcX2OETY+VOnQd7OBHx+hVSlrMU7iWL6oJ5rc+nQwteVA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2465,9 +2465,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2486,9 +2486,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2507,9 +2507,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2528,9 +2528,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -2549,9 +2549,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2573,9 +2573,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2597,9 +2597,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2621,9 +2621,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2645,9 +2645,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2666,9 +2666,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2808,9 +2808,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2953,9 +2953,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2966,9 +2966,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2986,7 +2986,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -3236,9 +3236,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
|
||||
"integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3269,9 +3269,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/stylelint": {
|
||||
"version": "17.14.0",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz",
|
||||
"integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==",
|
||||
"version": "17.14.1",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz",
|
||||
"integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3287,7 +3287,7 @@
|
|||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.2.1",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.5",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.6",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"@csstools/media-query-list-parser": "^5.0.0",
|
||||
"@csstools/selector-resolve-nested": "^4.0.0",
|
||||
|
|
@ -3299,9 +3299,9 @@
|
|||
"debug": "^4.4.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"file-entry-cache": "^11.1.3",
|
||||
"file-entry-cache": "^11.1.5",
|
||||
"global-modules": "^2.0.0",
|
||||
"globby": "^16.2.0",
|
||||
"globby": "^16.2.1",
|
||||
"globjoin": "^0.1.4",
|
||||
"html-tags": "^5.1.0",
|
||||
"ignore": "^7.0.5",
|
||||
|
|
@ -3311,12 +3311,12 @@
|
|||
"micromatch": "^4.0.8",
|
||||
"normalize-path": "^3.0.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss": "^8.5.16",
|
||||
"postcss-safe-parser": "^7.0.1",
|
||||
"postcss-selector-parser": "^7.1.4",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"string-width": "^8.2.1",
|
||||
"supports-hyperlinks": "^4.4.0",
|
||||
"supports-hyperlinks": "^4.5.0",
|
||||
"svg-tags": "^1.0.0",
|
||||
"table": "^6.9.0",
|
||||
"write-file-atomic": "^7.0.1"
|
||||
|
|
@ -3400,9 +3400,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/stylelint/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
|
||||
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,2 +1,2 @@
|
|||
(()=>{var l=class{constructor(){}async register(){let r=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(r.challenge),rp:{id:r.rp.id,name:r.rp.name},user:{id:new TextEncoder().encode(window.atob(r.user.id)),name:r.user.name,displayName:r.user.displayName},pubKeyCredParams:r.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:r.authenticatorSelection,timeout:6e4},t=await navigator.credentials.create({publicKey:e});if(!t)throw new Error("Error generating a passkey");let n={id:t.id?t.id:null,type:t.type?t.type:null,rawId:t.rawId?this.bufferToBase64URLString(t.rawId):null,response:{attestationObject:t.response.attestationObject?this.bufferToBase64URLString(t.response.attestationObject):null,clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let r=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(r.challenge),userVerification:r.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let t={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(t),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(r){let e=r.replace(/-/g,"+").replace(/_/g,"/"),t=(4-e.length%4)%4,n=e.padEnd(e.length+t,"="),a=window.atob(n),o=new ArrayBuffer(a.length),i=new Uint8Array(o);for(let s=0;s<a.length;s++)i[s]=a.charCodeAt(s);return o}bufferToBase64URLString(r){let e=new Uint8Array(r),t="";for(let a of e)t+=String.fromCharCode(a);return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector")}setupEventListeners(){this.widget.querySelectorAll("#theme-selector-dropdown input").forEach(r=>r.addEventListener("input",e=>{let t=e.target.value;this.widget.querySelectorAll(".toggle svg").forEach(u=>{u.classList.contains(t)?u.style.display="":u.style.display="none"});let n;switch(t){case"dark":case"light":n=t;break;default:n="light dark"}let a=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",o=document.querySelector("html"),i=o.style.getPropertyValue("color-scheme");i===""&&(i="light dark");let s=!1;n!==i&&(n==="light dark"?s=i!==a:i==="light dark"?s=n!==a:s=!0),o.style.viewTransitionName="changing-theme",s&&document.startViewTransition?document.startViewTransition(()=>{document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n)}):(document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n))}))}};var h=new l;document.querySelectorAll(".add-passkey").forEach(c=>{c.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(c=>{c.addEventListener("click",()=>{h.login()})});var p=new d;p.setupEventListeners();})();
|
||||
(()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c<n.length;c++)o[c]=n.charCodeAt(c);return a}bufferToBase64URLString(t){let e=new Uint8Array(t),r="";for(let n of e)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme="system"}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})();
|
||||
//# sourceMappingURL=app.js.map
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
163
resources/css/admin-tokens.css
Normal file
163
resources/css/admin-tokens.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
@layer components {
|
||||
.token-list-wrapper {
|
||||
margin-block-start: 1em;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: 16px;
|
||||
overflow: auto hidden;
|
||||
background: light-dark(
|
||||
oklch(99% 0.02 var(--primary-hue)),
|
||||
oklch(22% 0.05 var(--primary-hue))
|
||||
);
|
||||
}
|
||||
|
||||
.token-list {
|
||||
width: 100%;
|
||||
min-width: 640px;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.9em 1.2em;
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.8em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
tr.is-revoked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
a {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
th:nth-child(1),
|
||||
td:nth-child(1) {
|
||||
width: 31%;
|
||||
}
|
||||
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
th:nth-child(3),
|
||||
td:nth-child(3) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
th:nth-child(4),
|
||||
td:nth-child(4) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
th:nth-child(5),
|
||||
td:nth-child(5) {
|
||||
width: 14%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.7em;
|
||||
border-radius: 999px;
|
||||
background: light-dark(
|
||||
oklch(92% 0.05 var(--primary-hue)),
|
||||
oklch(35% 0.08 var(--primary-hue))
|
||||
);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.token-list button.revoke {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: transparent;
|
||||
border: 1px solid currentcolor;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentcolor;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-revoked {
|
||||
color: var(--clr-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token-list button.revoke {
|
||||
background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));
|
||||
color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.token-list button.revoke:hover {
|
||||
background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));
|
||||
}
|
||||
|
||||
.token-reveal {
|
||||
margin-block-end: 1em;
|
||||
padding: 1em 1.2em;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: 16px;
|
||||
background: light-dark(
|
||||
oklch(96% 0.08 145deg),
|
||||
oklch(28% 0.08 145deg)
|
||||
);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
padding: 0.5em 0.7em;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--clr-border);
|
||||
}
|
||||
}
|
||||
|
||||
.scope-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,3 +8,4 @@
|
|||
@import url('notes.css');
|
||||
@import url('pagination.css');
|
||||
@import url('theme-selector.css');
|
||||
@import url('admin-tokens.css');
|
||||
|
|
|
|||
|
|
@ -18,9 +18,19 @@
|
|||
}
|
||||
|
||||
> main {
|
||||
grid-column: 1/-1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
|
||||
> * {
|
||||
grid-column: 2/3;
|
||||
}
|
||||
|
||||
> .full-bleed {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
grid-column: 1/-1;
|
||||
margin-block-start: 2ex;
|
||||
|
|
|
|||
|
|
@ -1,70 +1,66 @@
|
|||
@layer components {
|
||||
#theme-selector {
|
||||
display: flex;
|
||||
justify-self: start;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
padding: .25rem;
|
||||
border-radius: 999px;
|
||||
background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);
|
||||
|
||||
button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 999px;
|
||||
padding: .375rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
transition: background-color .2s, color .2s;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--clr-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* This is the element with the [popover] attribute */
|
||||
#theme-selector-dropdown {
|
||||
svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);
|
||||
}
|
||||
|
||||
&[aria-pressed="true"]:hover {
|
||||
background-color: var(--clr-text);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
background-color: var(--clr-text);
|
||||
color: var(--clr-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
position-area: block-end span-inline-start;
|
||||
margin: 0;
|
||||
border: 2px solid var(--clr-border);
|
||||
background-color: var(--clr-background);
|
||||
color: var(--clr-text);
|
||||
|
||||
fieldset {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1ch;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* This is the element with the [popover] attribute
|
||||
* Here we are styling the open and closing animations
|
||||
*/
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#theme-selector-dropdown {
|
||||
&:popover-open {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
|
||||
/*
|
||||
* Start styles for the opening transition.
|
||||
* Added to :popover-open, but after the opened styles.
|
||||
*/
|
||||
@starting-style {
|
||||
transform: translateY(30px) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* End styles for the closing transition.
|
||||
*/
|
||||
transform: translateY(0) scale(0);
|
||||
opacity: 0;
|
||||
|
||||
/*
|
||||
* Enumerate transitioning properties, including display and overlay.
|
||||
*/
|
||||
transition: transform, opacity, display allow-discrete, overlay allow-discrete;
|
||||
transition-duration: 0.5s;
|
||||
transform-origin: top right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(changing-theme) {
|
||||
|
|
|
|||
|
|
@ -1,69 +1,60 @@
|
|||
class ThemeSelector {
|
||||
constructor() {
|
||||
this.widget = document.querySelector('#theme-selector');
|
||||
this.btnLight = this.widget.querySelector('#btn-light');
|
||||
this.btnDark = this.widget.querySelector('#btn-dark');
|
||||
this.status = this.widget.querySelector('#theme-status');
|
||||
this.currentTheme = 'system';
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => {
|
||||
let theme = e.target.value;
|
||||
|
||||
// Update current icon
|
||||
this.widget.querySelectorAll('.toggle svg').forEach((svg) => {
|
||||
if (svg.classList.contains(theme)) {
|
||||
svg.style.display = '';
|
||||
} else {
|
||||
svg.style.display = 'none';
|
||||
}
|
||||
this.btnLight.addEventListener('click', () => {
|
||||
this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');
|
||||
});
|
||||
|
||||
// Set the theme
|
||||
let selectedTheme;
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
case 'light':
|
||||
selectedTheme = theme;
|
||||
break;
|
||||
default:
|
||||
selectedTheme = 'light dark';
|
||||
this.btnDark.addEventListener('click', () => {
|
||||
this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
|
||||
});
|
||||
}
|
||||
|
||||
applyTheme(theme) {
|
||||
const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';
|
||||
|
||||
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
const html = document.querySelector('html');
|
||||
let currentTheme = html.style.getPropertyValue('color-scheme');
|
||||
if (currentTheme === '') {
|
||||
currentTheme = 'light dark';
|
||||
const html = document.documentElement;
|
||||
let currentColorScheme = html.style.getPropertyValue('color-scheme');
|
||||
if (currentColorScheme === '') {
|
||||
currentColorScheme = 'light dark';
|
||||
}
|
||||
|
||||
/* do we need to transition */
|
||||
let doTransition = false;
|
||||
|
||||
if (selectedTheme !== currentTheme) {
|
||||
if (selectedTheme !== currentColorScheme) {
|
||||
if (selectedTheme === 'light dark') {
|
||||
doTransition = currentTheme !== systemTheme;
|
||||
} else if (currentTheme === 'light dark') {
|
||||
doTransition = currentColorScheme !== systemTheme;
|
||||
} else if (currentColorScheme === 'light dark') {
|
||||
doTransition = selectedTheme !== systemTheme;
|
||||
} else {
|
||||
doTransition = true;
|
||||
}
|
||||
}
|
||||
|
||||
const applyChange = () => {
|
||||
this.currentTheme = theme;
|
||||
html.style.setProperty('color-scheme', selectedTheme);
|
||||
|
||||
this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));
|
||||
this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));
|
||||
this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';
|
||||
};
|
||||
|
||||
html.style.viewTransitionName = 'changing-theme';
|
||||
if (doTransition && document.startViewTransition) {
|
||||
document.startViewTransition(() => {
|
||||
// Close the popover
|
||||
document.querySelector('#theme-selector-dropdown').togglePopover();
|
||||
|
||||
// Set the colour theme
|
||||
html.style.setProperty('color-scheme', selectedTheme);
|
||||
});
|
||||
document.startViewTransition(applyChange);
|
||||
} else {
|
||||
// Close the popover
|
||||
document.querySelector('#theme-selector-dropdown').togglePopover();
|
||||
|
||||
// Set the colour theme
|
||||
html.style.setProperty('color-scheme', selectedTheme);
|
||||
applyChange();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
9
resources/views/about.blade.php
Normal file
9
resources/views/about.blade.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
@extends('master')
|
||||
@section('title')About « @stop
|
||||
|
||||
@section('content')
|
||||
<h2>About</h2>
|
||||
<div class="e-content">
|
||||
{!! $about !!}
|
||||
</div>
|
||||
@stop
|
||||
19
resources/views/admin/about/show.blade.php
Normal file
19
resources/views/admin/about/show.blade.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@extends('master')
|
||||
|
||||
@section('title')Edit About « Admin CP « @stop
|
||||
|
||||
@section('content')
|
||||
<h1>Edit About</h1>
|
||||
<form action="/admin/about" method="post" accept-charset="utf-8" class="admin-form form">
|
||||
{{ csrf_field() }}
|
||||
{{ method_field('PUT') }}
|
||||
<div>
|
||||
<label for="content">Content:</label>
|
||||
<br>
|
||||
<textarea name="content" id="content" rows="10" cols="50">{{ old('content', $aboutEntry?->content) }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" name="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@stop
|
||||
26
resources/views/admin/settings/show.blade.php
Normal file
26
resources/views/admin/settings/show.blade.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
@extends('master')
|
||||
|
||||
@section('title')Site Settings « Admin CP « @stop
|
||||
|
||||
@section('content')
|
||||
<h1>Site settings</h1>
|
||||
<form action="/admin/settings" method="post" accept-charset="utf-8" class="admin-form form">
|
||||
{{ csrf_field() }}
|
||||
{{ method_field('PUT') }}
|
||||
<div>
|
||||
<label for="winter_effect_enabled">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="winter_effect_enabled"
|
||||
id="winter_effect_enabled"
|
||||
value="1"
|
||||
@checked(old('winter_effect_enabled', $settings?->winter_effect_enabled))
|
||||
>
|
||||
Show winter snow effect
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" name="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@stop
|
||||
52
resources/views/admin/tokens/create.blade.php
Normal file
52
resources/views/admin/tokens/create.blade.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
@extends('master')
|
||||
|
||||
@section('title')New Token « Admin CP « @stop
|
||||
|
||||
@section('content')
|
||||
<h1>Generate a new token</h1>
|
||||
<p>Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.</p>
|
||||
|
||||
<form action="/admin/tokens" method="post" accept-charset="utf-8" class="admin-form form">
|
||||
{{ csrf_field() }}
|
||||
|
||||
<div>
|
||||
<label for="client_id">Client</label>
|
||||
<input
|
||||
type="text"
|
||||
name="client_id"
|
||||
id="client_id"
|
||||
value="{{ old('client_id') }}"
|
||||
placeholder="https://ia.net/writer"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="scope-checkboxes">
|
||||
<span>Scope</span>
|
||||
<label for="scope_create">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="scope[]"
|
||||
id="scope_create"
|
||||
value="create"
|
||||
@checked(in_array('create', old('scope', []), true))
|
||||
>
|
||||
create
|
||||
</label>
|
||||
<label for="scope_update">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="scope[]"
|
||||
id="scope_update"
|
||||
value="update"
|
||||
@checked(in_array('update', old('scope', []), true))
|
||||
>
|
||||
update
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" name="save">Generate token</button>
|
||||
</div>
|
||||
</form>
|
||||
@stop
|
||||
64
resources/views/admin/tokens/index.blade.php
Normal file
64
resources/views/admin/tokens/index.blade.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
@extends('master')
|
||||
|
||||
@section('title')List Tokens « Admin CP « @stop
|
||||
|
||||
@section('content')
|
||||
<h1>Micropub Tokens</h1>
|
||||
<p><a href="/admin/tokens/create">Generate new token</a></p>
|
||||
|
||||
@if(session('new_token'))
|
||||
<div class="token-reveal">
|
||||
<p>Here's your new token. <strong>Copy it now</strong> — it won't be shown again.</p>
|
||||
<input type="text" readonly value="{{ session('new_token') }}" onclick="this.select()">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($tokens->isEmpty())
|
||||
<p>No tokens have been issued.</p>
|
||||
@else
|
||||
<div class="full-bleed token-list-wrapper">
|
||||
<table class="token-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Client</th>
|
||||
<th scope="col">Scope</th>
|
||||
<th scope="col">Issued</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($tokens as $token)
|
||||
<tr class="{{ $token->isRevoked ? 'is-revoked' : '' }}">
|
||||
<td><a href="{{ $token->client_id }}" rel="noopener">{{ $token->client_id }}</a></td>
|
||||
<td>
|
||||
<span class="scope-chips">
|
||||
@foreach(explode(' ', $token->scope) as $scope)
|
||||
<span class="scope-chip">{{ $scope }}</span>
|
||||
@endforeach
|
||||
</span>
|
||||
</td>
|
||||
<td><time datetime="{{ $token->created_at->toIso8601String() }}" title="{{ $token->created_at }}">{{ $token->created_at->diffForHumans() }}</time></td>
|
||||
<td>
|
||||
@if($token->isRevoked)
|
||||
<span class="badge badge-revoked">Revoked {{ $token->revoked_at->diffForHumans() }}</span>
|
||||
@else
|
||||
<span class="badge badge-active"><span class="dot"></span>Active</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@unless($token->isRevoked)
|
||||
<form action="/admin/tokens/{{ $token->id }}/revoke" method="post" onsubmit="return confirm('Revoke this token? This cannot be undone.')">
|
||||
{{ csrf_field() }}
|
||||
{{ method_field('PUT') }}
|
||||
<button type="submit" class="revoke" name="revoke">Revoke</button>
|
||||
</form>
|
||||
@endunless
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
@stop
|
||||
|
|
@ -47,13 +47,28 @@
|
|||
or <a href="/admin/syndication">edit</a> them.
|
||||
</p>
|
||||
|
||||
<h2>Tokens</h2>
|
||||
<p>
|
||||
View and <a href="/admin/tokens">revoke</a> issued Micropub tokens.
|
||||
</p>
|
||||
|
||||
<h2>Bio</h2>
|
||||
<p>
|
||||
Edit your <a href="/admin/bio">bio</a>.
|
||||
</p>
|
||||
|
||||
<h2>About</h2>
|
||||
<p>
|
||||
Edit your <a href="/admin/about">about page</a>.
|
||||
</p>
|
||||
|
||||
<h2>Passkeys</h2>
|
||||
<p>
|
||||
Manager <a href="/admin/passkeys">your passkeys</a>.
|
||||
</p>
|
||||
|
||||
<h2>Settings</h2>
|
||||
<p>
|
||||
Edit <a href="/admin/settings">site settings</a>.
|
||||
</p>
|
||||
@stop
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Atom feed for {{ config('user.display_name') }}’s blog</title>
|
||||
<link rel="self" href="{{ config('app.url') }}/blog/feed.atom" />
|
||||
<id>{{ config('app.url')}}/blog</id>
|
||||
<updated>{{ $articles[0]->updated_at->toAtomString() }}</updated>
|
||||
|
||||
@foreach($articles as $article)
|
||||
<entry>
|
||||
<title>{{ $article->title }}</title>
|
||||
<link href="{{ config('app.url') }}{{ $article->link }}" />
|
||||
<id>{{ config('app.url') }}{{ $article->link }}</id>
|
||||
<updated>{{ $article->updated_at->toAtomString() }}</updated>
|
||||
<content>{{ $article->main }}</content>
|
||||
<author>
|
||||
<name>{{ config('user.display_name') }}</name>
|
||||
</author>
|
||||
</entry>
|
||||
@endforeach
|
||||
</feed>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>{{ config('user.display_name') }}</title>
|
||||
<atom:link href="{{ config('app.url') }}/blog/feed.rss" rel="self" type="application/rss+xml" />
|
||||
<description>An RSS feed of the blog posts found on {{ config('app.url') }}</description>
|
||||
<link>{{ config('app.url') }}/blog</link>
|
||||
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
|
||||
<ttl>1800</ttl>
|
||||
|
||||
@foreach($articles as $article)
|
||||
<item>
|
||||
<title>{{ strip_tags($article->title) }}</title>
|
||||
<description>
|
||||
<![CDATA[
|
||||
{{ $article->main }}
|
||||
@if($article->url)<p><a href="{{ config('app.url') }}{{ $article->link }}">Permalink</a></p>@endif
|
||||
]]>
|
||||
</description>
|
||||
<link>@if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif</link>
|
||||
<guid>{{ config('app.url') }}{{ $article->link }}</guid>
|
||||
<pubDate>{{ $article->pubdate }}</pubDate>
|
||||
</item>
|
||||
@endforeach
|
||||
</channel>
|
||||
</rss>
|
||||
11
resources/views/icons/json-feed.blade.php
Normal file
11
resources/views/icons/json-feed.blade.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
@php
|
||||
if (isset($title)) {
|
||||
$uniqueId = bin2hex(random_bytes(6));
|
||||
}
|
||||
@endphp
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 200 200" class="feather feather-json-feed"
|
||||
@if($title)aria-labelledby="{{ $uniqueId }}"@endif
|
||||
>
|
||||
@if($title)<title id="{{ $uniqueId }}">{{ $title }}</title>@endif
|
||||
<path fill="#497714" transform="translate(0,200) scale(0.1,-0.1)" d="M373 1547 c-88 -95 -127 -171 -127 -252 -1 -90 28 -140 164 -280 150 -155 160 -168 160 -219 0 -31 -9 -55 -41 -101 -34 -51 -39 -65 -34 -95 3 -20 18 -46 36 -62 44 -41 89 -38 163 12 32 22 67 40 78 40 46 0 99 -38 227 -164 169 -165 209 -190 305 -190 92 -1 158 32 258 125 l73 67 -59 61 -60 60 -39 -36 c-22 -19 -60 -44 -84 -55 -73 -32 -109 -12 -278 152 -77 76 -156 145 -175 155 -47 24 -110 22 -159 -6 -23 -12 -44 -19 -48 -15 -4 4 2 25 15 46 24 42 29 110 12 154 -5 15 -66 84 -135 156 -138 143 -165 180 -165 227 0 47 15 78 61 128 l42 45 -54 59 c-30 32 -57 59 -61 60 -3 0 -37 -32 -75 -72z M1474 1631 c-36 -22 -58 -75 -48 -115 10 -41 59 -76 106 -76 107 0 137 147 41 199 -31 16 -64 14 -99 -8z M1229 1385 c-55 -30 -73 -89 -44 -145 19 -37 43 -50 95 -50 110 0 139 144 40 195 -36 18 -57 18 -91 0z M949 1111 c-86 -87 29 -228 130 -159 40 27 52 62 41 115 -12 51 -41 73 -97 73 -36 0 -50 -6 -74 -29z"/>
|
||||
</svg>
|
||||
|
|
@ -7,13 +7,9 @@
|
|||
<title>@yield('title'){{ config('app.name') }}</title>
|
||||
<link rel="stylesheet" href="/assets/highlight/nord.css">
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<link rel="alternate" type="application/rss+xml" title="Blog RSS Feed" href="{{ route('feed.blog.rss') }}">
|
||||
<link rel="alternate" type="application/atom+xml" title="Blog Atom Feed" href="{{ route('feed.blog.atom') }}">
|
||||
<link rel="alternate" type="application/json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
|
||||
<link rel="alternate" type="application/feed+json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
|
||||
<link rel="alternate" type="application/jf2feed+json" title="Blog JF2 Feed" href="{{ route('feed.blog.jf2') }}">
|
||||
<link rel="alternate" type="application/rss+xml" title="Notes RSS Feed" href="{{ route('feed.notes.rss') }}">
|
||||
<link rel="alternate" type="application/atom+xml" title="Notes Atom Feed" href="{{ route('feed.notes.atom') }}">
|
||||
<link rel="alternate" type="application/json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
|
||||
<link rel="alternate" type="application/feed+json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
|
||||
<link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="{{ route('feed.notes.jf2') }}">
|
||||
<link rel="indieauth-metadata" href="{{ route('indieauth.metadata') }}">
|
||||
<link rel="authorization_endpoint" href="{{ route('indieauth.start') }}">
|
||||
|
|
@ -40,37 +36,17 @@
|
|||
<a href="/likes">Likes</a>
|
||||
<a href="/contacts">Contacts</a>
|
||||
<a href="/projects">Projects</a>
|
||||
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.rss', ['title' => 'RSS Feed'])</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a>
|
||||
</nav>
|
||||
<div id="theme-selector">
|
||||
<button class="toggle" popovertarget="theme-selector-dropdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="system" style=""><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="dark" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="light" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
|
||||
<div id="theme-selector" role="region" aria-label="Theme switcher">
|
||||
<button id="btn-light" aria-label="Light mode" aria-pressed="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
|
||||
</button>
|
||||
<div id="theme-selector-dropdown" popover>
|
||||
<fieldset>
|
||||
<legend>Select theme:</legend>
|
||||
<div>
|
||||
<input type="radio" id="theme-system" name="theme" value="system" checked>
|
||||
<label for="theme-system">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="theme-dark" name="theme" value="dark">
|
||||
<label for="theme-dark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="theme-light" name="theme" value="light">
|
||||
<label for="theme-light">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<button id="btn-dark" aria-label="Dark mode" aria-pressed="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
|
||||
</button>
|
||||
<span id="theme-status" class="sr-only" aria-live="polite"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -106,6 +82,7 @@
|
|||
@section('scripts')
|
||||
<script type="module" src="/assets/js/app.js"></script>
|
||||
|
||||
@if($winterEffectEnabled ?? false)
|
||||
<script type="module" src="/assets/js/is-land.min.js"></script>
|
||||
<script type="module" src="/assets/js/winter.js"></script>
|
||||
<is-land on:media="(prefers-reduced-motion: no-preference)">
|
||||
|
|
@ -114,6 +91,7 @@
|
|||
style="--snow-fall-color: var(--clr-snow-fall)"
|
||||
></snow-fall>
|
||||
</is-land>
|
||||
@endif
|
||||
@show
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Atom feed for {{ config('user.display_name') }}’s notes</title>
|
||||
<link rel="self" href="{{ config('app.url') }}/notes/feed.atom" />
|
||||
<id>{{ config('app.url')}}/notes</id>
|
||||
<updated>{{ $notes[0]->updated_at->toAtomString() }}</updated>
|
||||
|
||||
@foreach($notes as $note)
|
||||
<entry>
|
||||
<title>{{ strip_tags($note->note) }}</title>
|
||||
<link href="{{ $note->uri }}" />
|
||||
<id>{{ $note->uri }}</id>
|
||||
<updated>{{ $note->updated_at->toAtomString() }}</updated>
|
||||
<content type="html">{{ $note->note }}</content>
|
||||
<author>
|
||||
<name>{{ config('user.display_name') }}</name>
|
||||
</author>
|
||||
</entry>
|
||||
@endforeach
|
||||
</feed>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>{{ config('user.display_name') }}</title>
|
||||
<atom:link href="{{ config('app.url') }}/notes/feed.rss" rel="self" type="application/rss+xml" />
|
||||
<description>An RSS feed of the notes found on {{ config('app.url') }}</description>
|
||||
<link>{{ config('app.url') }}/notes</link>
|
||||
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
|
||||
<ttl>1800</ttl>
|
||||
|
||||
@foreach($notes as $note)
|
||||
<item>
|
||||
<title>{{ strip_tags($note->note) }}</title>
|
||||
<description>
|
||||
<![CDATA[
|
||||
{!! $note->note !!}
|
||||
]]>
|
||||
</description>
|
||||
<link>{{ $note->uri }}</link>
|
||||
<guid>{{ $note->uri}}</guid>
|
||||
<pubDate>{{ $note->pubdate }}</pubDate>
|
||||
</item>
|
||||
@endforeach
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AboutPageController;
|
||||
use App\Http\Controllers\Admin\AboutController;
|
||||
use App\Http\Controllers\Admin\ArticlesController as AdminArticlesController;
|
||||
use App\Http\Controllers\Admin\BioController;
|
||||
use App\Http\Controllers\Admin\ClientsController;
|
||||
|
|
@ -9,7 +11,9 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController;
|
|||
use App\Http\Controllers\Admin\NotesController as AdminNotesController;
|
||||
use App\Http\Controllers\Admin\PasskeysController;
|
||||
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
|
||||
use App\Http\Controllers\Admin\SettingsController;
|
||||
use App\Http\Controllers\Admin\SyndicationTargetsController;
|
||||
use App\Http\Controllers\Admin\TokensController;
|
||||
use App\Http\Controllers\ArticlesController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\BookmarksController;
|
||||
|
|
@ -49,6 +53,9 @@ Route::view('projects', 'projects');
|
|||
// Static colophon page
|
||||
Route::view('colophon', 'colophon');
|
||||
|
||||
// About page
|
||||
Route::get('about', [AboutPageController::class, 'show']);
|
||||
|
||||
// The login routes to get auth’d for admin
|
||||
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
||||
Route::post('login', [AuthController::class, 'login']);
|
||||
|
|
@ -147,12 +154,32 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
|
|||
Route::delete('/{clientId}', [ClientsController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// Micropub Tokens
|
||||
Route::prefix('tokens')->group(function () {
|
||||
Route::get('/', [TokensController::class, 'index']);
|
||||
Route::get('/create', [TokensController::class, 'create']);
|
||||
Route::post('/', [TokensController::class, 'store']);
|
||||
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
|
||||
});
|
||||
|
||||
// Bio
|
||||
Route::prefix('bio')->group(function () {
|
||||
Route::get('/', [BioController::class, 'show'])->name('admin.bio.show');
|
||||
Route::put('/', [BioController::class, 'update']);
|
||||
});
|
||||
|
||||
// Settings
|
||||
Route::prefix('settings')->group(function () {
|
||||
Route::get('/', [SettingsController::class, 'show'])->name('admin.settings.show');
|
||||
Route::put('/', [SettingsController::class, 'update']);
|
||||
});
|
||||
|
||||
// About
|
||||
Route::prefix('about')->group(function () {
|
||||
Route::get('/', [AboutController::class, 'show'])->name('admin.about.show');
|
||||
Route::put('/', [AboutController::class, 'update']);
|
||||
});
|
||||
|
||||
// Passkeys
|
||||
Route::prefix('passkeys')->group(function () {
|
||||
Route::get('/', [PasskeysController::class, 'index']);
|
||||
|
|
@ -163,8 +190,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
|
|||
|
||||
// Blog pages using ArticlesController
|
||||
Route::prefix('blog')->group(function () {
|
||||
Route::get('/feed.rss', [FeedsController::class, 'blogRss'])->name('feed.blog.rss');
|
||||
Route::get('/feed.atom', [FeedsController::class, 'blogAtom'])->name('feed.blog.atom');
|
||||
Route::get('/feed.json', [FeedsController::class, 'blogJson'])->name('feed.blog.json');
|
||||
Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2');
|
||||
Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']);
|
||||
|
|
@ -175,8 +200,6 @@ Route::prefix('blog')->group(function () {
|
|||
// Notes pages using NotesController
|
||||
Route::prefix('notes')->group(function () {
|
||||
Route::get('/', [NotesController::class, 'index']);
|
||||
Route::get('/feed.rss', [FeedsController::class, 'notesRss'])->name('feed.notes.rss');
|
||||
Route::get('/feed.atom', [FeedsController::class, 'notesAtom'])->name('feed.notes.atom');
|
||||
Route::get('/feed.json', [FeedsController::class, 'notesJson'])->name('feed.notes.json');
|
||||
Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2');
|
||||
Route::get('/new', [NotesController::class, 'create']);
|
||||
|
|
@ -205,6 +228,8 @@ Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMidd
|
|||
Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class);
|
||||
Route::post('auth', [IndieAuthController::class, 'processCodeExchange']);
|
||||
Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token');
|
||||
Route::post('revocation', [IndieAuthController::class, 'processRevocationRequest'])->name('indieauth.revocation');
|
||||
Route::post('introspect', [IndieAuthController::class, 'processIntrospectionRequest'])->name('indieauth.introspection');
|
||||
|
||||
// Micropub Endpoints
|
||||
Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class);
|
||||
|
|
|
|||
26
tests/Feature/AboutPageTest.php
Normal file
26
tests/Feature/AboutPageTest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\About;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AboutPageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function about_page_shows_content(): void
|
||||
{
|
||||
About::factory()->create([
|
||||
'content' => 'This is the about page content.',
|
||||
]);
|
||||
|
||||
$this->get('/about')
|
||||
->assertSee('This is the about page content.');
|
||||
}
|
||||
}
|
||||
68
tests/Feature/Admin/AboutTest.php
Normal file
68
tests/Feature/Admin/AboutTest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\About;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AboutTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function admin_about_page_loads(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get('/admin/about');
|
||||
$response->assertSeeText('Edit About');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_create_about(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/admin/about', [
|
||||
'_method' => 'PUT',
|
||||
'content' => 'About content',
|
||||
]);
|
||||
$this->assertDatabaseHas('about', ['content' => 'About content']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_load_existing_about(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
$about = About::factory()->create([
|
||||
'content' => 'This is <em>my</em> about page. It uses <strong>HTML</strong>.',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get('/admin/about');
|
||||
$response->assertSeeText('This is <em>my</em> about page. It uses <strong>HTML</strong>.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_edit_about(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
$about = About::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/admin/about', [
|
||||
'_method' => 'PUT',
|
||||
'content' => 'This about page has been edited',
|
||||
]);
|
||||
$this->assertDatabaseHas('about', [
|
||||
'content' => 'This about page has been edited',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,9 @@ namespace Tests\Feature\Admin;
|
|||
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -141,14 +138,12 @@ class ContactsTest extends TestCase
|
|||
<img class="u-photo" alt="" src="http://tantek.com/tantek.png">
|
||||
</div>
|
||||
HTML;
|
||||
$file = fopen(__DIR__.'/../../aaron.png', 'rb');
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'text/html'], $html),
|
||||
new Response(200, ['Content-Type' => 'image/png'], $file),
|
||||
$file = file_get_contents(__DIR__.'/../../aaron.png');
|
||||
Http::fake([
|
||||
'*' => Http::sequence()
|
||||
->push($html, 200, ['Content-Type' => 'text/html'])
|
||||
->push($file, 200, ['Content-Type' => 'image/png']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$user = User::factory()->make();
|
||||
$contact = Contact::factory()->create([
|
||||
'homepage' => 'https://tantek.com',
|
||||
|
|
@ -165,12 +160,9 @@ class ContactsTest extends TestCase
|
|||
#[Test]
|
||||
public function getting_remote_avatar_fails_gracefully_with_remote_not_found(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(404),
|
||||
Http::fake([
|
||||
'*' => Http::response('', 404),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$user = User::factory()->make();
|
||||
$contact = Contact::factory()->create();
|
||||
|
||||
|
|
@ -187,13 +179,11 @@ class ContactsTest extends TestCase
|
|||
<img class="u-photo" src="http://tantek.com/tantek.png">
|
||||
</div>
|
||||
HTML;
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'text/html'], $html),
|
||||
new Response(404),
|
||||
Http::fake([
|
||||
'*' => Http::sequence()
|
||||
->push($html, 200, ['Content-Type' => 'text/html'])
|
||||
->push('', 404),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$user = User::factory()->make();
|
||||
$contact = Contact::factory()->create();
|
||||
|
||||
|
|
|
|||
68
tests/Feature/Admin/SettingsTest.php
Normal file
68
tests/Feature/Admin/SettingsTest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function admin_settings_page_loads(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get('/admin/settings');
|
||||
$response->assertSeeText('Site settings');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_enable_winter_effect(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/admin/settings', [
|
||||
'_method' => 'PUT',
|
||||
'winter_effect_enabled' => '1',
|
||||
]);
|
||||
$this->assertDatabaseHas('settings', ['winter_effect_enabled' => true]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_disable_winter_effect(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
Setting::factory()->create(['winter_effect_enabled' => true]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/admin/settings', [
|
||||
'_method' => 'PUT',
|
||||
]);
|
||||
$this->assertDatabaseHas('settings', ['winter_effect_enabled' => false]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function winter_effect_markup_is_hidden_when_disabled(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
$response->assertDontSee('snow-fall');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function winter_effect_markup_is_shown_when_enabled(): void
|
||||
{
|
||||
Setting::factory()->create(['winter_effect_enabled' => true]);
|
||||
|
||||
$response = $this->get('/');
|
||||
$response->assertSee('snow-fall', false);
|
||||
}
|
||||
}
|
||||
154
tests/Feature/Admin/TokensTest.php
Normal file
154
tests/Feature/Admin/TokensTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\MicropubToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TokensTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function index_requires_authentication(): void
|
||||
{
|
||||
$response = $this->get('/admin/tokens');
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function index_lists_issued_tokens(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
$token = MicropubToken::create([
|
||||
'token_hash' => hash('sha256', 'a-token'),
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.uk',
|
||||
'scope' => 'create update',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/admin/tokens');
|
||||
$response->assertOk();
|
||||
$response->assertSeeText($token->client_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function create_requires_authentication(): void
|
||||
{
|
||||
$response = $this->get('/admin/tokens/create');
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function create_shows_form(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
|
||||
$response = $this->actingAs($user)->get('/admin/tokens/create');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('name="client_id"', false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function store_requires_authentication(): void
|
||||
{
|
||||
$response = $this->post('/admin/tokens', [
|
||||
'client_id' => 'https://ia.net/writer',
|
||||
'scope' => ['create'],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('micropub_tokens', 0);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function store_creates_a_new_token_and_redirects_with_it_flashed(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
|
||||
$response = $this->actingAs($user)->post('/admin/tokens', [
|
||||
'client_id' => 'https://ia.net/writer',
|
||||
'scope' => ['create', 'update'],
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/admin/tokens');
|
||||
$response->assertSessionHas('new_token');
|
||||
|
||||
$this->assertDatabaseHas('micropub_tokens', [
|
||||
'client_id' => 'https://ia.net/writer',
|
||||
'scope' => 'create update',
|
||||
'me' => config('app.url'),
|
||||
]);
|
||||
|
||||
$token = $response->getSession()->get('new_token');
|
||||
$this->assertNotNull(MicropubToken::findActive($token));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function store_requires_at_least_one_scope(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
|
||||
$response = $this->actingAs($user)->post('/admin/tokens', [
|
||||
'client_id' => 'https://ia.net/writer',
|
||||
'scope' => [],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('scope');
|
||||
$this->assertDatabaseCount('micropub_tokens', 0);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revoke_requires_authentication(): void
|
||||
{
|
||||
$token = MicropubToken::create([
|
||||
'token_hash' => hash('sha256', 'a-token'),
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.uk',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
|
||||
$response = $this->put("/admin/tokens/{$token->id}/revoke");
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertFalse($token->fresh()->isRevoked);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revoke_marks_the_token_as_revoked(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
$token = MicropubToken::create([
|
||||
'token_hash' => hash('sha256', 'a-token'),
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.uk',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
|
||||
|
||||
$this->assertTrue($token->fresh()->isRevoked);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revoke_redirects_to_index(): void
|
||||
{
|
||||
$user = User::factory()->make();
|
||||
$token = MicropubToken::create([
|
||||
'token_hash' => hash('sha256', 'a-token'),
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.uk',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
|
||||
|
||||
$response->assertRedirect('/admin/tokens');
|
||||
}
|
||||
}
|
||||
29
tests/Feature/CsrfExemptionsTest.php
Normal file
29
tests/Feature/CsrfExemptionsTest.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CsrfExemptionsTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* CSRF verification is short-circuited entirely while running the test
|
||||
* suite (see PreventRequestForgery::runningUnitTests()), so a normal
|
||||
* feature test hitting these routes would pass even if they were never
|
||||
* added to bootstrap/app.php's except list. Assert against the actual
|
||||
* configured exemptions instead.
|
||||
*/
|
||||
#[Test]
|
||||
public function external_api_endpoints_are_exempt_from_csrf_verification(): void
|
||||
{
|
||||
$exemptions = $this->app->make(PreventRequestForgery::class)->getExcludedPaths();
|
||||
|
||||
foreach (['auth', 'token', 'revocation', 'introspect', 'api/post', 'api/media', 'micropub/places', 'webmention'] as $path) {
|
||||
$this->assertContains($path, $exemptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,42 +15,6 @@ class FeedsTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test the blog RSS feed.
|
||||
*/
|
||||
#[Test]
|
||||
public function blog_rss_feed_is_present(): void
|
||||
{
|
||||
Article::factory()->count(3)->create();
|
||||
$response = $this->get('/blog/feed.rss');
|
||||
$response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the notes RSS feed.
|
||||
*/
|
||||
#[Test]
|
||||
public function notes_rss_feed_is_present(): void
|
||||
{
|
||||
Note::factory()->count(3)->create();
|
||||
$response = $this->get('/notes/feed.rss');
|
||||
$response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the blog RSS feed.
|
||||
*/
|
||||
#[Test]
|
||||
public function blog_atom_feed_is_present(): void
|
||||
{
|
||||
Article::factory()->count(3)->create();
|
||||
$response = $this->get('/blog/feed.atom');
|
||||
$response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blog_jf2_feed_is_present(): void
|
||||
{
|
||||
|
|
@ -73,18 +37,6 @@ class FeedsTest extends TestCase
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the notes RSS feed.
|
||||
*/
|
||||
#[Test]
|
||||
public function notes_atom_feed_is_present(): void
|
||||
{
|
||||
Note::factory()->count(3)->create();
|
||||
$response = $this->get('/notes/feed.atom');
|
||||
$response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the blog JSON feed.
|
||||
*/
|
||||
|
|
@ -93,7 +45,7 @@ class FeedsTest extends TestCase
|
|||
{
|
||||
Article::factory()->count(3)->create();
|
||||
$response = $this->get('/blog/feed.json');
|
||||
$response->assertHeader('Content-Type', 'application/json');
|
||||
$response->assertHeader('Content-Type', 'application/feed+json');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +57,7 @@ class FeedsTest extends TestCase
|
|||
{
|
||||
Note::factory()->count(3)->create();
|
||||
$response = $this->get('/notes/feed.json');
|
||||
$response->assertHeader('Content-Type', 'application/json');
|
||||
$response->assertHeader('Content-Type', 'application/feed+json');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ class HeaderLinkTest extends TestCase
|
|||
$this->assertSame('<'.config('app.url').'/.well-known/indieauth-server>; rel="indieauth-metadata"', $linkHeaders[0]);
|
||||
$this->assertSame('<'.config('app.url').'/auth>; rel="authorization_endpoint"', $linkHeaders[1]);
|
||||
$this->assertSame('<'.config('app.url').'/token>; rel="token_endpoint"', $linkHeaders[2]);
|
||||
$this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[3]);
|
||||
$this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[4]);
|
||||
$this->assertSame('<'.config('app.url').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]);
|
||||
$this->assertSame('<'.config('app.url').'/introspect>; rel="introspection_endpoint"', $linkHeaders[4]);
|
||||
$this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[5]);
|
||||
$this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[6]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MicropubToken;
|
||||
use App\Models\User;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use App\Services\TokenService;
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use GuzzleHttp\Psr7\UriResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -30,9 +29,10 @@ class IndieAuthTest extends TestCase
|
|||
'issuer' => config('app.url'),
|
||||
'authorization_endpoint' => route('indieauth.start'),
|
||||
'token_endpoint' => route('indieauth.token'),
|
||||
'revocation_endpoint' => route('indieauth.revocation'),
|
||||
'introspection_endpoint' => route('indieauth.introspection'),
|
||||
'introspection_endpoint_auth_methods_supported' => ['Bearer'],
|
||||
'code_challenge_methods_supported' => ['S256'],
|
||||
// 'introspection_endpoint' => 'introspection_endpoint',
|
||||
// 'introspection_endpoint_auth_methods_supported' => ['none'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -269,12 +269,9 @@ class IndieAuthTest extends TestCase
|
|||
</html>
|
||||
HTML;
|
||||
|
||||
$mockHandler = new MockHandler([
|
||||
new Response(200, [], $appPageHtml),
|
||||
Http::fake([
|
||||
'*' => Http::response($appPageHtml, 200),
|
||||
]);
|
||||
$handlerStack = HandlerStack::create($mockHandler);
|
||||
$mockGuzzleClient = new Client(['handler' => $handlerStack]);
|
||||
$this->app->instance(Client::class, $mockGuzzleClient);
|
||||
|
||||
$user = User::factory()->make();
|
||||
$url = url()->query('/auth', [
|
||||
|
|
@ -313,12 +310,9 @@ class IndieAuthTest extends TestCase
|
|||
</html>
|
||||
HTML;
|
||||
|
||||
$mockHandler = new MockHandler([
|
||||
new Response(200, [], $appPageHtml),
|
||||
Http::fake([
|
||||
'*' => Http::response($appPageHtml, 200),
|
||||
]);
|
||||
$handlerStack = HandlerStack::create($mockHandler);
|
||||
$mockGuzzleClient = new Client(['handler' => $handlerStack]);
|
||||
$this->app->instance(Client::class, $mockGuzzleClient);
|
||||
|
||||
$user = User::factory()->make();
|
||||
$url = url()->query('/auth', [
|
||||
|
|
@ -701,4 +695,132 @@ class IndieAuthTest extends TestCase
|
|||
'me' => config('app.url'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_should_revoke_a_known_token(): void
|
||||
{
|
||||
$token = resolve(TokenService::class)->getNewToken([
|
||||
'me' => config('app.url'),
|
||||
'client_id' => 'https://app.example.com',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
|
||||
$response = $this->post('/revocation', ['token' => $token]);
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertTrue(
|
||||
MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->isRevoked
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_should_return200_for_an_unknown_token(): void
|
||||
{
|
||||
$response = $this->post('/revocation', ['token' => bin2hex(random_bytes(32))]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function introspection_requires_a_bearer_token(): void
|
||||
{
|
||||
$response = $this->post('/introspect', ['token' => 'irrelevant']);
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function introspection_rejects_a_revoked_bearer_token(): void
|
||||
{
|
||||
$callerToken = resolve(TokenService::class)->getNewToken([
|
||||
'me' => config('app.url'),
|
||||
'client_id' => 'https://app.example.com',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
MicropubToken::where('token_hash', hash('sha256', $callerToken))->firstOrFail()->revoke();
|
||||
|
||||
$response = $this->post(
|
||||
'/introspect',
|
||||
['token' => 'irrelevant'],
|
||||
['HTTP_Authorization' => 'Bearer '.$callerToken]
|
||||
);
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function introspection_returns_active_details_for_a_valid_token(): void
|
||||
{
|
||||
$callerToken = resolve(TokenService::class)->getNewToken([
|
||||
'me' => config('app.url'),
|
||||
'client_id' => 'https://app.example.com',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
$subjectToken = resolve(TokenService::class)->getNewToken([
|
||||
'me' => 'https://someone-else.example.com',
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'scope' => 'create update',
|
||||
]);
|
||||
|
||||
$response = $this->post(
|
||||
'/introspect',
|
||||
['token' => $subjectToken],
|
||||
['HTTP_Authorization' => 'Bearer '.$callerToken]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'active' => true,
|
||||
'me' => 'https://someone-else.example.com',
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'scope' => 'create update',
|
||||
]);
|
||||
$response->assertJsonStructure(['iat']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function introspection_returns_only_active_false_for_an_unknown_token(): void
|
||||
{
|
||||
$callerToken = resolve(TokenService::class)->getNewToken([
|
||||
'me' => config('app.url'),
|
||||
'client_id' => 'https://app.example.com',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
|
||||
$response = $this->post(
|
||||
'/introspect',
|
||||
['token' => bin2hex(random_bytes(32))],
|
||||
['HTTP_Authorization' => 'Bearer '.$callerToken]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertExactJson(['active' => false]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revocation_does_not_error_on_an_array_shaped_token_param(): void
|
||||
{
|
||||
$response = $this->post('/revocation', ['token' => ['a', 'b']]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function introspection_does_not_error_on_an_array_shaped_token_param(): void
|
||||
{
|
||||
$callerToken = resolve(TokenService::class)->getNewToken([
|
||||
'me' => config('app.url'),
|
||||
'client_id' => 'https://app.example.com',
|
||||
'scope' => 'create',
|
||||
]);
|
||||
|
||||
$response = $this->post(
|
||||
'/introspect',
|
||||
['token' => ['a', 'b']],
|
||||
['HTTP_Authorization' => 'Bearer '.$callerToken]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertExactJson(['active' => false]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@ namespace Tests\Feature;
|
|||
|
||||
use App\Jobs\ProcessLike;
|
||||
use App\Models\Like;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
|
@ -98,18 +95,12 @@ class LikesTest extends TestCase
|
|||
</html>
|
||||
END;
|
||||
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $content),
|
||||
new Response(200, [], $content),
|
||||
Http::fake([
|
||||
'*' => Http::response($content, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->bind(Client::class, function () use ($client) {
|
||||
return $client;
|
||||
});
|
||||
$authorship = new Authorship;
|
||||
|
||||
$job->handle($client, $authorship);
|
||||
$job->handle($authorship);
|
||||
|
||||
$this->assertEquals('Fred Bloggs', Like::find($id)->author_name);
|
||||
}
|
||||
|
|
@ -141,18 +132,12 @@ class LikesTest extends TestCase
|
|||
</html>
|
||||
END;
|
||||
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $content),
|
||||
new Response(200, [], $content),
|
||||
Http::fake([
|
||||
'*' => Http::response($content, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->bind(Client::class, function () use ($client) {
|
||||
return $client;
|
||||
});
|
||||
$authorship = new Authorship;
|
||||
|
||||
$job->handle($client, $authorship);
|
||||
$job->handle($authorship);
|
||||
|
||||
$this->assertEquals('Fred Bloggs', Like::find($id)->author_name);
|
||||
}
|
||||
|
|
@ -177,18 +162,12 @@ class LikesTest extends TestCase
|
|||
</html>
|
||||
END;
|
||||
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $content),
|
||||
new Response(200, [], $content),
|
||||
Http::fake([
|
||||
'*' => Http::response($content, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->bind(Client::class, function () use ($client) {
|
||||
return $client;
|
||||
});
|
||||
$authorship = new Authorship;
|
||||
|
||||
$job->handle($client, $authorship);
|
||||
$job->handle($authorship);
|
||||
|
||||
$this->assertNull(Like::find($id)->author_name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ declare(strict_types=1);
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exceptions\MicropubHandlerException;
|
||||
use App\Jobs\SendWebMentions;
|
||||
use App\Jobs\SyndicateNoteToBluesky;
|
||||
use App\Jobs\SyndicateNoteToMastodon;
|
||||
use App\Models\Article;
|
||||
use App\Models\Media;
|
||||
use App\Models\Note;
|
||||
use App\Models\Place;
|
||||
use App\Models\SyndicationTarget;
|
||||
use Faker\Factory;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
|
@ -457,6 +460,11 @@ class MicropubControllerTest extends TestCase
|
|||
#[Test]
|
||||
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void
|
||||
{
|
||||
$this->mock(ExceptionHandler::class)
|
||||
->shouldReceive('report')
|
||||
->once()
|
||||
->with(\Mockery::type(MicropubHandlerException::class));
|
||||
|
||||
$response = $this->postJson(
|
||||
'/api/post',
|
||||
[
|
||||
|
|
@ -869,6 +877,99 @@ class MicropubControllerTest extends TestCase
|
|||
$this->assertDatabaseHas('articles', [
|
||||
'title' => $name,
|
||||
'main' => $content,
|
||||
'published' => true,
|
||||
]);
|
||||
$response->assertHeader('Location');
|
||||
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void
|
||||
{
|
||||
$faker = Factory::create();
|
||||
$name = $faker->text(50);
|
||||
$content = $faker->paragraphs(5, true);
|
||||
|
||||
$response = $this->postJson(
|
||||
'/api/post',
|
||||
[
|
||||
'type' => ['h-entry'],
|
||||
'properties' => [
|
||||
'name' => [$name],
|
||||
'content' => [$content],
|
||||
'post-status' => ['draft'],
|
||||
],
|
||||
],
|
||||
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
|
||||
);
|
||||
|
||||
$response
|
||||
->assertJson(['response' => 'created'])
|
||||
->assertStatus(201);
|
||||
$this->assertDatabaseHas('articles', [
|
||||
'title' => $name,
|
||||
'main' => $content,
|
||||
'published' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void
|
||||
{
|
||||
$draft = Article::create([
|
||||
'title' => 'WireGuard',
|
||||
'main' => 'Early draft content',
|
||||
'published' => false,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
'/api/post',
|
||||
[
|
||||
'type' => ['h-entry'],
|
||||
'properties' => [
|
||||
'name' => ['WireGuard'],
|
||||
'content' => ['Finished content'],
|
||||
],
|
||||
],
|
||||
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
|
||||
);
|
||||
|
||||
$response
|
||||
->assertJson(['response' => 'created'])
|
||||
->assertStatus(201);
|
||||
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
|
||||
$this->assertDatabaseHas('articles', [
|
||||
'id' => $draft->id,
|
||||
'title' => 'WireGuard',
|
||||
'main' => 'Finished content',
|
||||
'published' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void
|
||||
{
|
||||
Article::create([
|
||||
'title' => 'WireGuard',
|
||||
'main' => 'Published content',
|
||||
'published' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
'/api/post',
|
||||
[
|
||||
'type' => ['h-entry'],
|
||||
'properties' => [
|
||||
'name' => ['WireGuard'],
|
||||
'content' => ['Some other content'],
|
||||
],
|
||||
],
|
||||
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
|
||||
);
|
||||
|
||||
$response
|
||||
->assertJson(['error' => 'invalid_request'])
|
||||
->assertStatus(400);
|
||||
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,16 @@ declare(strict_types=1);
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MicropubToken;
|
||||
use App\Services\TokenService;
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TokenServiceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Given the token is dependent on a random nonce, the time of creation and
|
||||
* the APP_KEY, to test, we shall create a token, and then verify it.
|
||||
* Given the token is dependent on a random value and stored only as a
|
||||
* hash, to test, we shall create a token, and then verify it.
|
||||
*/
|
||||
#[Test]
|
||||
public function tokenservice_creates_valid_tokens(): void
|
||||
|
|
@ -41,24 +39,29 @@ class TokenServiceTest extends TestCase
|
|||
}
|
||||
|
||||
#[Test]
|
||||
public function tokens_with_different_signing_key_are_not_valid(): void
|
||||
public function unknown_tokens_are_not_valid(): void
|
||||
{
|
||||
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.bin2hex(random_bytes(32))]);
|
||||
|
||||
$response->assertJson([
|
||||
'response' => 'error',
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'The provided token did not pass validation',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revoked_tokens_are_not_valid(): void
|
||||
{
|
||||
$tokenService = new TokenService;
|
||||
$data = [
|
||||
'me' => 'https://example.org',
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'scope' => 'post',
|
||||
];
|
||||
$token = $tokenService->getNewToken($data);
|
||||
|
||||
$config = resolve(Configuration::class);
|
||||
|
||||
$token = $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', $data['client_id'])
|
||||
->withClaim('me', $data['me'])
|
||||
->withClaim('scope', $data['scope'])
|
||||
->withClaim('nonce', bin2hex(random_bytes(8)))
|
||||
->getToken($config->signer(), InMemory::plainText(random_bytes(32)))
|
||||
->toString();
|
||||
MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke();
|
||||
|
||||
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]);
|
||||
|
||||
|
|
@ -68,4 +71,18 @@ class TokenServiceTest extends TestCase
|
|||
'error_description' => 'The provided token did not pass validation',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request input for a "string" field can be sent as an array
|
||||
* (e.g. token[]=a&token[]=b). Casting that to string throws in this app
|
||||
* (warnings are promoted to exceptions), so findActive() must guard
|
||||
* against it rather than assume its caller already validated the type.
|
||||
*/
|
||||
#[Test]
|
||||
public function find_active_treats_non_string_input_as_absent(): void
|
||||
{
|
||||
$this->assertNull(MicropubToken::findActive(['a', 'b']));
|
||||
$this->assertNull(MicropubToken::findActive(null));
|
||||
$this->assertNull(MicropubToken::findActive(123));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,19 @@
|
|||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Http::preventStrayRequests();
|
||||
}
|
||||
|
||||
public function removeDirIfEmpty(string $dir): void
|
||||
{
|
||||
// scandir() will always return `.` and `..` so even an “empty”
|
||||
|
|
|
|||
|
|
@ -2,53 +2,39 @@
|
|||
|
||||
namespace Tests;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use App\Services\TokenService;
|
||||
|
||||
trait TestToken
|
||||
{
|
||||
public function getToken(): string
|
||||
{
|
||||
$config = $this->app->make(Configuration::class);
|
||||
|
||||
return $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', 'https://quill.p3k.io')
|
||||
->withClaim('me', 'http://jonnybarnes.localhost')
|
||||
->withClaim('scope', ['create', 'update'])
|
||||
->getToken($config->signer(), $config->signingKey())
|
||||
->toString();
|
||||
return $this->app->make(TokenService::class)->getNewToken([
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'http://jonnybarnes.localhost',
|
||||
'scope' => 'create update',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getTokenWithIncorrectScope(): string
|
||||
{
|
||||
$config = $this->app->make(Configuration::class);
|
||||
|
||||
return $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', 'https://quill.p3k.io')
|
||||
->withClaim('me', 'https://jonnybarnes.localhost')
|
||||
->withClaim('scope', 'view')
|
||||
->getToken($config->signer(), $config->signingKey())
|
||||
->toString();
|
||||
return $this->app->make(TokenService::class)->getNewToken([
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.localhost',
|
||||
'scope' => 'view',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getTokenWithNoScope()
|
||||
public function getTokenWithNoScope(): string
|
||||
{
|
||||
$config = $this->app->make(Configuration::class);
|
||||
|
||||
return $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', 'https://quill.p3k.io')
|
||||
->withClaim('me', 'https://jonnybarnes.localhost')
|
||||
->getToken($config->signer(), $config->signingKey())
|
||||
->toString();
|
||||
return $this->app->make(TokenService::class)->getNewToken([
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.localhost',
|
||||
'scope' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getInvalidToken()
|
||||
public function getInvalidToken(): string
|
||||
{
|
||||
$token = $this->getToken();
|
||||
|
||||
return substr($token, 0, -5);
|
||||
return bin2hex(random_bytes(32));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,28 @@ class ArticlesTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uri_is_the_absolute_form_of_the_link(): void
|
||||
{
|
||||
$article = Article::create([
|
||||
'title' => 'Test',
|
||||
'main' => 'Test',
|
||||
]);
|
||||
|
||||
$this->assertEquals(config('app.url').$article->link, $article->uri);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function slug_is_suffixed_when_a_trashed_article_already_used_it(): void
|
||||
{
|
||||
$original = Article::create(['title' => 'My Title', 'main' => 'Content']);
|
||||
$original->delete();
|
||||
|
||||
$newArticle = Article::create(['title' => 'My Title', 'main' => 'Other content']);
|
||||
|
||||
$this->assertEquals('my-title-2', $newArticle->titleurl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function date_scope_returns_expected_articles(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ namespace Tests\Unit;
|
|||
|
||||
use App\Exceptions\InternetArchiveException;
|
||||
use App\Services\BookmarkService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -29,12 +26,9 @@ class BookmarksTest extends TestCase
|
|||
#[Test]
|
||||
public function archive_link_method_calls_archive_service(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Content-Location' => '/web/1234/example.org']),
|
||||
Http::fake([
|
||||
'web.archive.org/*' => Http::response('', 200, ['Content-Location' => '/web/1234/example.org']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$url = (new BookmarkService)->getArchiveLink('https://example.org');
|
||||
$this->assertEquals('/web/1234/example.org', $url);
|
||||
}
|
||||
|
|
@ -44,12 +38,9 @@ class BookmarksTest extends TestCase
|
|||
{
|
||||
$this->expectException(InternetArchiveException::class);
|
||||
|
||||
$mock = new MockHandler([
|
||||
new Response(403),
|
||||
Http::fake([
|
||||
'web.archive.org/*' => Http::response('', 403),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
(new BookmarkService)->getArchiveLink('https://example.org');
|
||||
}
|
||||
|
||||
|
|
@ -58,12 +49,9 @@ class BookmarksTest extends TestCase
|
|||
{
|
||||
$this->expectException(InternetArchiveException::class);
|
||||
|
||||
$mock = new MockHandler([
|
||||
new Response(200),
|
||||
Http::fake([
|
||||
'web.archive.org/*' => Http::response('', 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
(new BookmarkService)->getArchiveLink('https://example.org');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,8 @@ declare(strict_types=1);
|
|||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Jobs\DownloadWebMention;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\FileSystem\FileSystem;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -35,19 +32,16 @@ class DownloadWebMentionJobTest extends TestCase
|
|||
</div>
|
||||
HTML;
|
||||
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['X-Foo' => 'Bar'], $html),
|
||||
new Response(200, ['X-Foo' => 'Bar'], $html),
|
||||
Http::fake([
|
||||
'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$job = new DownloadWebMention($source);
|
||||
$job->handle($client);
|
||||
$job->handle();
|
||||
|
||||
$this->assertFileExists(storage_path('HTML/https'));
|
||||
|
||||
$job->handle($client);
|
||||
$job->handle();
|
||||
|
||||
$this->assertFileDoesNotExist(storage_path('HTML/https/example.org/reply').'/1.'.date('Y-m-d').'.backup');
|
||||
}
|
||||
|
|
@ -70,19 +64,18 @@ class DownloadWebMentionJobTest extends TestCase
|
|||
HTML;
|
||||
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
|
||||
$html2 = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html2);
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['X-Foo' => 'Bar'], $html),
|
||||
new Response(200, ['X-Foo' => 'Bar'], $html2),
|
||||
Http::fake([
|
||||
'example.org/*' => Http::sequence()
|
||||
->push($html, 200, ['X-Foo' => 'Bar'])
|
||||
->push($html2, 200, ['X-Foo' => 'Bar']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$job = new DownloadWebMention($source);
|
||||
$job->handle($client);
|
||||
$job->handle();
|
||||
|
||||
$this->assertFileExists(storage_path('HTML/https'));
|
||||
|
||||
$job->handle($client);
|
||||
$job->handle();
|
||||
|
||||
$this->assertFileExists(storage_path('HTML/https/example.org/reply').'/1.'.date('Y-m-d').'.backup');
|
||||
}
|
||||
|
|
@ -98,14 +91,12 @@ class DownloadWebMentionJobTest extends TestCase
|
|||
</div>
|
||||
HTML;
|
||||
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['X-Foo' => 'Bar'], $html),
|
||||
Http::fake([
|
||||
'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$job = new DownloadWebMention($source);
|
||||
$job->handle($client);
|
||||
$job->handle();
|
||||
|
||||
$this->assertFileExists(storage_path('HTML/https/example.org/reply-one/index.html'));
|
||||
}
|
||||
|
|
|
|||
71
tests/Unit/Jobs/NotifyBrrrOfWebMentionJobTest.php
Normal file
71
tests/Unit/Jobs/NotifyBrrrOfWebMentionJobTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Jobs\NotifyBrrrOfWebMention;
|
||||
use App\Models\WebMention;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotifyBrrrOfWebMentionJobTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_posts_a_reply_notification_to_the_brrr_webhook(): void
|
||||
{
|
||||
config(['services.brrr.webhook_url' => 'https://api.brrr.now/v1/br_usr_test']);
|
||||
Http::fake();
|
||||
|
||||
$webMention = WebMention::factory()->make([
|
||||
'source' => 'https://example.org/reply/1',
|
||||
'target' => 'https://jonnybarnes.uk/notes/1',
|
||||
'type' => 'in-reply-to',
|
||||
]);
|
||||
|
||||
$job = new NotifyBrrrOfWebMention($webMention);
|
||||
$job->handle();
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://api.brrr.now/v1/br_usr_test'
|
||||
&& $request['title'] === 'New reply'
|
||||
&& $request['message'] === 'From https://example.org/reply/1'
|
||||
&& $request['open_url'] === 'https://jonnybarnes.uk/notes/1';
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_titles_notifications_by_webmention_type(): void
|
||||
{
|
||||
config(['services.brrr.webhook_url' => 'https://api.brrr.now/v1/br_usr_test']);
|
||||
Http::fake();
|
||||
|
||||
foreach ([
|
||||
'in-reply-to' => 'New reply',
|
||||
'like-of' => 'New like',
|
||||
'repost-of' => 'New repost',
|
||||
'something-else' => 'New webmention',
|
||||
] as $type => $expectedTitle) {
|
||||
$webMention = WebMention::factory()->make(['type' => $type]);
|
||||
|
||||
(new NotifyBrrrOfWebMention($webMention))->handle();
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request['title'] === $expectedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_does_nothing_when_no_webhook_url_is_configured(): void
|
||||
{
|
||||
config(['services.brrr.webhook_url' => null]);
|
||||
Http::fake();
|
||||
|
||||
$webMention = WebMention::factory()->make();
|
||||
|
||||
(new NotifyBrrrOfWebMention($webMention))->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ namespace Tests\Unit\Jobs;
|
|||
|
||||
use App\Jobs\ProcessMedia;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\ImageManager;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -15,10 +14,9 @@ class ProcessMediaJobTest extends TestCase
|
|||
#[Test]
|
||||
public function non_media_files_are_not_saved(): void
|
||||
{
|
||||
$manager = app()->make(ImageManager::class);
|
||||
Storage::disk('local')->put('media/file.txt', 'This is not an image');
|
||||
$job = new ProcessMedia('file.txt');
|
||||
$job->handle($manager);
|
||||
$job = new ProcessMedia('media/file.txt');
|
||||
$job->handle();
|
||||
|
||||
$this->assertFileDoesNotExist(storage_path('app/media/').'file.txt');
|
||||
}
|
||||
|
|
@ -26,10 +24,9 @@ class ProcessMediaJobTest extends TestCase
|
|||
#[Test]
|
||||
public function small_images_are_not_resized(): void
|
||||
{
|
||||
$manager = app()->make(ImageManager::class);
|
||||
Storage::disk('local')->put('media/aaron.png', file_get_contents(__DIR__.'/../../aaron.png'));
|
||||
$job = new ProcessMedia('aaron.png');
|
||||
$job->handle($manager);
|
||||
$job = new ProcessMedia('media/aaron.png');
|
||||
$job->handle();
|
||||
|
||||
$this->assertFileDoesNotExist(storage_path('app/media/').'aaron.png');
|
||||
|
||||
|
|
@ -41,10 +38,9 @@ class ProcessMediaJobTest extends TestCase
|
|||
#[Test]
|
||||
public function large_images_have_smaller_images_created(): void
|
||||
{
|
||||
$manager = app()->make(ImageManager::class);
|
||||
Storage::disk('local')->put('media/test-image.jpg', file_get_contents(__DIR__.'/../../test-image.jpg'));
|
||||
$job = new ProcessMedia('media/test-image.jpg');
|
||||
$job->handle($manager);
|
||||
$job->handle();
|
||||
|
||||
// These need to look in public disk
|
||||
Storage::disk('public')->assertExists('media/test-image.jpg');
|
||||
|
|
|
|||
|
|
@ -5,16 +5,14 @@ declare(strict_types=1);
|
|||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Exceptions\RemoteContentNotFoundException;
|
||||
use App\Jobs\NotifyBrrrOfWebMention;
|
||||
use App\Jobs\ProcessWebMention;
|
||||
use App\Jobs\SaveProfileImage;
|
||||
use App\Models\Note;
|
||||
use App\Models\WebMention;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\FileSystem\FileSystem;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Jonnybarnes\WebmentionsParser\Parser;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
|
@ -39,17 +37,15 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
$this->expectException(RemoteContentNotFoundException::class);
|
||||
|
||||
$parser = new Parser;
|
||||
$mock = new MockHandler([
|
||||
new Response(404),
|
||||
Http::fake([
|
||||
'*' => Http::response('', 404),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$note = Note::factory()->create();
|
||||
$source = 'https://example.org/mention/1/';
|
||||
|
||||
$job = new ProcessWebMention($note, $source);
|
||||
$job->handle($parser, $client);
|
||||
$job->handle($parser);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
|
@ -65,19 +61,18 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
</div>
|
||||
HTML;
|
||||
$html = str_replace('href="', 'href="'.config('app.url'), $html);
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$note = Note::factory()->create();
|
||||
$source = 'https://example.org/mention/1/';
|
||||
|
||||
$job = new ProcessWebMention($note, $source);
|
||||
$job->handle($parser, $client);
|
||||
$job->handle($parser);
|
||||
|
||||
Queue::assertPushed(SaveProfileImage::class);
|
||||
Queue::assertPushed(NotifyBrrrOfWebMention::class);
|
||||
$this->assertDatabaseHas('webmentions', [
|
||||
'source' => $source,
|
||||
'type' => 'like-of',
|
||||
|
|
@ -103,16 +98,15 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
<div class="e-content">Updated reply</div>
|
||||
</div>
|
||||
HTML;
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$job = new ProcessWebMention($note, $source);
|
||||
$job->handle($parser, $client);
|
||||
$job->handle($parser);
|
||||
|
||||
Queue::assertPushed(SaveProfileImage::class);
|
||||
Queue::assertNotPushed(NotifyBrrrOfWebMention::class);
|
||||
$this->assertDatabaseHas('webmentions', [
|
||||
'source' => $source,
|
||||
'type' => 'in-reply-to',
|
||||
|
|
@ -132,11 +126,9 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
<div class="e-content">Replying to someone else</div>
|
||||
</div>
|
||||
HTML;
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$note = Note::factory()->create();
|
||||
$source = 'https://example.org/reply/1';
|
||||
|
|
@ -151,7 +143,7 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
]);
|
||||
|
||||
$job = new ProcessWebMention($note, $source);
|
||||
$job->handle($parser, $client);
|
||||
$job->handle($parser);
|
||||
|
||||
$this->assertDatabaseMissing('webmentions', [
|
||||
'source' => $source,
|
||||
|
|
@ -169,11 +161,9 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
<div class="e-content">I like someone else now</div>
|
||||
</div>
|
||||
HTML;
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$note = Note::factory()->create();
|
||||
$source = 'https://example.org/reply/1';
|
||||
|
|
@ -188,7 +178,7 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
]);
|
||||
|
||||
$job = new ProcessWebMention($note, $source);
|
||||
$job->handle($parser, $client);
|
||||
$job->handle($parser);
|
||||
|
||||
$this->assertDatabaseMissing('webmentions', [
|
||||
'source' => $source,
|
||||
|
|
@ -208,19 +198,18 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
</div>
|
||||
HTML;
|
||||
$html = str_replace('href="', 'href="'.config('app.url'), $html);
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$note = Note::factory()->create();
|
||||
// Simulate a long brid.gy Bluesky source URL (well over 255 characters)
|
||||
$source = 'https://brid.gy/comment/bluesky/did:plc:n3jhgiq2ykctnpgzlm6p6b25/at%253A%252F%252Fdid%253Aplc%253An3jhgiq2ykctnpgzlm6p6b25%252Fapp.bsky.feed.post%252F3mfekjuhykb2k/at%253A%252F%252Fdid%253Aplc%253Afsfuwigo5juzdwp23hyianzg%252Fapp.bsky.feed.post%252F3mfemw4rrvs2t';
|
||||
|
||||
$job = new ProcessWebMention($note, $source);
|
||||
$job->handle($parser, $client);
|
||||
$job->handle($parser);
|
||||
|
||||
Queue::assertPushed(NotifyBrrrOfWebMention::class);
|
||||
$this->assertGreaterThan(255, strlen($source));
|
||||
$this->assertDatabaseHas('webmentions', [
|
||||
'source' => $source,
|
||||
|
|
@ -238,11 +227,9 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
<div class="e-content">Reposting someone else</div>
|
||||
</div>
|
||||
HTML;
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
|
||||
$note = Note::factory()->create();
|
||||
$source = 'https://example.org/reply/1';
|
||||
|
|
@ -257,7 +244,7 @@ class ProcessWebMentionJobTest extends TestCase
|
|||
]);
|
||||
|
||||
$job = new ProcessWebMention($note, $source);
|
||||
$job->handle($parser, $client);
|
||||
$job->handle($parser);
|
||||
|
||||
$this->assertDatabaseMissing('webmentions', [
|
||||
'source' => $source,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ declare(strict_types=1);
|
|||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Jobs\SaveProfileImage;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
|
@ -58,12 +55,9 @@ class SaveProfileImageJobTest extends TestCase
|
|||
#[Test]
|
||||
public function remote_author_images_are_saved_locally(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'),
|
||||
Http::fake([
|
||||
'*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$mf = ['items' => []];
|
||||
$author = [
|
||||
'properties' => [
|
||||
|
|
@ -83,12 +77,9 @@ class SaveProfileImageJobTest extends TestCase
|
|||
#[Test]
|
||||
public function local_default_author_image_is_used_as_fallback(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(404),
|
||||
Http::fake([
|
||||
'*' => Http::response('', 404),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$mf = ['items' => []];
|
||||
$author = [
|
||||
'properties' => [
|
||||
|
|
@ -111,12 +102,9 @@ class SaveProfileImageJobTest extends TestCase
|
|||
#[Test]
|
||||
public function we_get_url_from_photo_object_if_alt_text_is_provided(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'),
|
||||
Http::fake([
|
||||
'*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$mf = ['items' => []];
|
||||
$author = [
|
||||
'properties' => [
|
||||
|
|
@ -139,12 +127,9 @@ class SaveProfileImageJobTest extends TestCase
|
|||
#[Test]
|
||||
public function use_first_url_if_multiple_homepages_are_provided(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'),
|
||||
Http::fake([
|
||||
'*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
$mf = ['items' => []];
|
||||
$author = [
|
||||
'properties' => [
|
||||
|
|
|
|||
|
|
@ -6,13 +6,8 @@ namespace Tests\Unit\Jobs;
|
|||
|
||||
use App\Jobs\SaveScreenshot;
|
||||
use App\Models\Bookmark;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -25,57 +20,34 @@ class SaveScreenshotJobTest extends TestCase
|
|||
public function screenshot_is_saved_by_job(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$guzzleMock = new MockHandler([
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')),
|
||||
|
||||
Http::fake([
|
||||
'api.cloudconvert.com/v2/capture-website' => Http::response([
|
||||
'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished'],
|
||||
], 201),
|
||||
'api.cloudconvert.com/v2/tasks/68d52633-e170-465e-b13e-746c97d01ffb*' => Http::response([
|
||||
'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished'],
|
||||
], 200),
|
||||
'api.cloudconvert.com/v2/export/url' => Http::response([
|
||||
'data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'finished'],
|
||||
], 201),
|
||||
'api.cloudconvert.com/v2/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb*' => Http::response([
|
||||
'data' => [
|
||||
'id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb',
|
||||
'status' => 'finished',
|
||||
'result' => [
|
||||
'files' => [[
|
||||
'url' => 'https://storage.cloudconvert.com/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb/theverge.com.png',
|
||||
]],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
'storage.cloudconvert.com/*' => Http::response(
|
||||
file_get_contents(__DIR__.'/../../theverge.com.png'),
|
||||
200,
|
||||
['Content-Type' => 'image/png']
|
||||
),
|
||||
]);
|
||||
$guzzleHandler = HandlerStack::create($guzzleMock);
|
||||
$guzzleClient = new Client(['handler' => $guzzleHandler]);
|
||||
$this->app->instance(Client::class, $guzzleClient);
|
||||
$retryMock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
]);
|
||||
$retryHandler = HandlerStack::create($retryMock);
|
||||
$retryHandler->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 ConnectException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on server errors
|
||||
if ($response && $response->getStatusCode() >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$responseBody = '';
|
||||
|
||||
if (is_string($response)) {
|
||||
$responseBody = $response;
|
||||
}
|
||||
|
||||
if ($response instanceof Response) {
|
||||
$responseBody = $response->getBody()->getContents();
|
||||
$response->getBody()->rewind();
|
||||
}
|
||||
|
||||
// Finally for CloudConvert, retry if status is not final
|
||||
return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished';
|
||||
},
|
||||
function () {
|
||||
// Retry after 1 second
|
||||
return 1000;
|
||||
}
|
||||
));
|
||||
$retryClient = new Client(['handler' => $retryHandler]);
|
||||
$this->app->instance('RetryGuzzle', $retryClient);
|
||||
|
||||
$bookmark = Bookmark::factory()->create();
|
||||
$job = new SaveScreenshot($bookmark);
|
||||
|
|
@ -84,68 +56,45 @@ class SaveScreenshotJobTest extends TestCase
|
|||
|
||||
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
|
||||
Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png');
|
||||
|
||||
// capture-website, 1x poll (finished immediately), export/url, 1x poll (finished immediately), download
|
||||
Http::assertSentCount(5);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function screenshot_job_handles_unfinished_tasks(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$guzzleMock = new MockHandler([
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"id":1,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"id":2,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')),
|
||||
|
||||
Http::fake([
|
||||
'api.cloudconvert.com/v2/capture-website' => Http::response([
|
||||
'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'waiting'],
|
||||
], 201),
|
||||
'api.cloudconvert.com/v2/tasks/68d52633-e170-465e-b13e-746c97d01ffb*' => Http::sequence()
|
||||
->push(['data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'waiting']], 200)
|
||||
->push(['data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished']], 200),
|
||||
'api.cloudconvert.com/v2/export/url' => Http::response([
|
||||
'data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'waiting'],
|
||||
], 201),
|
||||
'api.cloudconvert.com/v2/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb*' => Http::sequence()
|
||||
->push(['data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'waiting']], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
'id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb',
|
||||
'status' => 'finished',
|
||||
'result' => [
|
||||
'files' => [[
|
||||
'url' => 'https://storage.cloudconvert.com/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb/theverge.com.png',
|
||||
]],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
'storage.cloudconvert.com/*' => Http::response(
|
||||
file_get_contents(__DIR__.'/../../theverge.com.png'),
|
||||
200,
|
||||
['Content-Type' => 'image/png']
|
||||
),
|
||||
]);
|
||||
$guzzleHandler = HandlerStack::create($guzzleMock);
|
||||
$guzzleClient = new Client(['handler' => $guzzleHandler]);
|
||||
$this->app->instance(Client::class, $guzzleClient);
|
||||
$container = [];
|
||||
$history = Middleware::history($container);
|
||||
$retryMock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":3,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":1,"code":null,"message":null,"percent":50,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":4,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":5,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":0,"code":null,"message":null,"percent":50,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":6,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
]);
|
||||
$retryHandler = HandlerStack::create($retryMock);
|
||||
$retryHandler->push($history);
|
||||
$retryHandler->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 ConnectException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on server errors
|
||||
if ($response && $response->getStatusCode() >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$responseBody = '';
|
||||
|
||||
if (is_string($response)) {
|
||||
$responseBody = $response;
|
||||
}
|
||||
|
||||
if ($response instanceof Response) {
|
||||
$responseBody = $response->getBody()->getContents();
|
||||
$response->getBody()->rewind();
|
||||
}
|
||||
|
||||
// Finally for CloudConvert, retry if status is not final
|
||||
return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished';
|
||||
},
|
||||
function () {
|
||||
// Retry after 1 second
|
||||
return 1000;
|
||||
}
|
||||
));
|
||||
$retryClient = new Client(['handler' => $retryHandler]);
|
||||
$this->app->instance('RetryGuzzle', $retryClient);
|
||||
|
||||
$bookmark = Bookmark::factory()->create();
|
||||
$job = new SaveScreenshot($bookmark);
|
||||
|
|
@ -154,9 +103,10 @@ class SaveScreenshotJobTest extends TestCase
|
|||
|
||||
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
|
||||
Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png');
|
||||
// Also assert we made the correct number of requests
|
||||
$this->assertCount(2, $container);
|
||||
// However with retries there should be more than 4 responses for the 2 requests
|
||||
$this->assertEquals(0, $retryMock->count());
|
||||
|
||||
// capture-website, 2x poll (waiting then finished), export/url, 2x poll (waiting then finished), download
|
||||
Http::assertSentCount(7);
|
||||
// Also assert every queued response in each sequence was consumed, no more no less
|
||||
Http::assertSequencesAreEmpty();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ namespace Tests\Unit\Jobs;
|
|||
|
||||
use App\Jobs\SendWebMentions;
|
||||
use App\Models\Note;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -28,12 +25,9 @@ class SendWebMentionJobTest extends TestCase
|
|||
public function discover_webmention_endpoint_from_header_links(): void
|
||||
{
|
||||
$url = 'https://example.org/webmention';
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Link' => '<'.$url.'>; rel="webmention"']),
|
||||
Http::fake([
|
||||
'*' => Http::response('', 200, ['Link' => '<'.$url.'>; rel="webmention"']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
|
||||
$job = new SendWebMentions(new Note);
|
||||
$this->assertEquals($url, $job->discoverWebmentionEndpoint('https://example.org'));
|
||||
|
|
@ -43,12 +37,9 @@ class SendWebMentionJobTest extends TestCase
|
|||
public function discover_webmention_endpoint_from_html_link_tags(): void
|
||||
{
|
||||
$html = '<link rel="webmention" href="https://example.org/webmention">';
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
|
||||
$job = new SendWebMentions(new Note);
|
||||
$this->assertEquals(
|
||||
|
|
@ -61,12 +52,9 @@ class SendWebMentionJobTest extends TestCase
|
|||
public function discover_webmention_endpoint_from_legacy_html_markup(): void
|
||||
{
|
||||
$html = '<link rel="http://webmention.org/" href="https://example.org/webmention">';
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
Http::fake([
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
|
||||
$job = new SendWebMentions(new Note);
|
||||
$this->assertEquals(
|
||||
|
|
@ -95,13 +83,10 @@ class SendWebMentionJobTest extends TestCase
|
|||
public function we_send_a_webmention_for_a_note(): void
|
||||
{
|
||||
$html = '<link rel="http://webmention.org/" href="https://example.org/webmention">';
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], $html),
|
||||
new Response(202),
|
||||
Http::fake([
|
||||
'example.org/webmention' => Http::response('', 202),
|
||||
'*' => Http::response($html, 200),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
|
||||
$note = new Note;
|
||||
$note->note = 'Hi [Aaron](https://aaronparecki.com)';
|
||||
|
|
@ -114,13 +99,10 @@ class SendWebMentionJobTest extends TestCase
|
|||
#[Test]
|
||||
public function links_in_notes_can_not_support_webmentions(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
Http::fake([
|
||||
// URLs with commas currently break the parse function I’m using
|
||||
new Response(200, ['Link' => '<https://example.org/foo,bar>; rel="preconnect"']),
|
||||
'*' => Http::response('', 200, ['Link' => '<https://example.org/foo,bar>; rel="preconnect"']),
|
||||
]);
|
||||
$handler = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->app->instance(Client::class, $client);
|
||||
|
||||
$job = new SendWebMentions(new Note);
|
||||
$this->assertNull($job->discoverWebmentionEndpoint('https://example.org'));
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue