diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 72242150..1d26894f 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -12,9 +12,12 @@ use App\Models\Place; use App\Models\SyndicationTarget; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\MicropubHandlerRegistry; +use App\Services\Micropub\NoteResolver; +use App\Services\Micropub\NoteSource; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; class MicropubController extends Controller { @@ -46,8 +49,15 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); $dataClass = $handler->dataClass(); - /** @var MicropubData $data */ - $data = $dataClass::fromRequest($request); + + try { + /** @var MicropubData $data */ + $data = $dataClass::fromRequest($request); + } catch (\TypeError) { + // the data objects are typed, so this means a property has the wrong shape + return $this->invalidRequest('A property in the request has an invalid value'); + } + $result = $handler->handle($data); if ($result['response'] === 'updated') { @@ -64,31 +74,17 @@ class MicropubController extends Controller return response()->json([ 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', - ], 401); + ], 403); } catch (ModelNotFoundException) { return response()->json([ 'error' => 'invalid_request', 'error_description' => 'No known note with given ID', ], 404); - } catch (MicropubUnsupportedModelException $e) { - report($e); - - return response()->json([ - 'error' => 'invalid', - 'error_description' => 'This implementation currently only supports the updating of notes', - ], 500); - } catch (\InvalidArgumentException $e) { + } catch (MicropubUnsupportedModelException|MicropubHandlerException|\InvalidArgumentException $e) { return response()->json([ 'error' => 'invalid_request', 'error_description' => $e->getMessage(), ], 400); - } catch (MicropubHandlerException $e) { - report($e); - - return response()->json([ - 'error' => 'unsupported_operation', - 'error_description' => 'The request could not be processed by this server', - ], 500); } catch (\Throwable $e) { report($e); @@ -103,40 +99,36 @@ class MicropubController extends Controller * Respond to a GET request to the micropub endpoint. * * Token validation is handled by the VerifyMicropubToken middleware. - * Supports q=syndicate-to, q=config, and q=geo:, queries. + * Supports q=syndicate-to, q=config, q=source, and q=geo:, queries. * The default response returns the token metadata. */ public function get(Request $request): JsonResponse { - if ($request->input('q') === 'syndicate-to') { + $q = $request->input('q'); + + if ($q === 'syndicate-to') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), ]); } - if ($request->input('q') === 'config') { + if ($q === 'config') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), 'media-endpoint' => route('media-endpoint'), ]); } - if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) { - preg_match_all( - '/([0-9.\-]+)/', - $request->input('q'), - $matches - ); - $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; - $places = Place::near( - (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], - $distance - )->get(); + if ($q === 'source') { + return $this->sourceResponse($request); + } - return response()->json([ - 'response' => 'places', - 'places' => $places, - ]); + if (is_string($q) && str_starts_with($q, 'geo:')) { + return $this->placesResponse($q); + } + + if ($request->has('q')) { + return $this->invalidRequest('This server does not know how to handle this q parameter'); } // the default response is just to return the token data @@ -152,4 +144,59 @@ class MicropubController extends Controller ], ]); } + + /** + * Respond to a q=source query with the microformats2 JSON of a post. + */ + private function sourceResponse(Request $request): JsonResponse + { + $url = $request->input('url'); + + if (! is_string($url)) { + return $this->invalidRequest('The url parameter is required'); + } + + try { + $note = resolve(NoteResolver::class)->fromUrl($url); + } catch (MicropubUnsupportedModelException $e) { + return $this->invalidRequest($e->getMessage()); + } catch (ModelNotFoundException) { + return $this->invalidRequest('No known note with given ID', 404); + } + + $requested = array_values(array_filter(Arr::wrap($request->input('properties')), 'is_string')); + + return response()->json(resolve(NoteSource::class)->toArray($note, $requested)); + } + + /** + * Respond to a q=geo:, query with the nearby places. + */ + private function placesResponse(string $q): JsonResponse + { + preg_match_all('/([0-9.\-]+)/', $q, $matches); + + if (count($matches[0]) < 2 || count(array_filter($matches[0], 'is_numeric')) !== count($matches[0])) { + return $this->invalidRequest('The geo query must contain a latitude and longitude'); + } + + $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; + $places = Place::near( + (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], + $distance + )->get(); + + return response()->json([ + 'response' => 'places', + 'places' => $places, + ]); + } + + private function invalidRequest(string $description, int $status = 400): JsonResponse + { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => $description, + ], $status); + } } diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index ab4fde74..7cbabc31 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -15,25 +15,36 @@ class MicropubRequest extends FormRequest ]; } + /** + * Determine the request type: the action (e.g. "update") if one is given, + * otherwise the microformat type without its "h-" prefix. Per the spec, a + * request with no type is treated as an h-entry. + */ public function getType(): ?string { if ($this->isJson()) { $data = $this->json()->all(); - if (isset($data['action']) && $data['action'] === 'update') { - return 'update'; + if (isset($data['action']) && is_string($data['action'])) { + return $data['action']; } if (isset($data['type']) && is_array($data['type'])) { $type = current($data['type']); - if (str_starts_with($type, 'h-')) { + if (is_string($type) && str_starts_with($type, 'h-')) { return substr($type, 2); } + + return null; } - return null; + return 'entry'; } - return $this->input('h') ?: null; + if ($this->filled('action')) { + return $this->input('action'); + } + + return $this->input('h') ?: 'entry'; } } diff --git a/app/Http/Responses/MicropubResponses.php b/app/Http/Responses/MicropubResponses.php index 4f7240c2..d26dbf90 100644 --- a/app/Http/Responses/MicropubResponses.php +++ b/app/Http/Responses/MicropubResponses.php @@ -17,7 +17,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'insufficient_scope', 'error_description' => 'The token’s scope does not have the necessary requirements.', - ], 401); + ], 403); } /** @@ -29,7 +29,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'invalid_token', 'error_description' => 'The provided token did not pass validation', - ], 400); + ], 401); } /** diff --git a/app/Models/Bookmark.php b/app/Models/Bookmark.php index 1c5ae6e4..5b37d84c 100644 --- a/app/Models/Bookmark.php +++ b/app/Models/Bookmark.php @@ -25,7 +25,7 @@ class Bookmark extends Model return $this->belongsToMany('App\Models\Tag'); } - protected function localUri(): Attribute + protected function uri(): Attribute { return Attribute::get( get: fn () => config('app.url').'/bookmarks/'.$this->id, diff --git a/app/Models/Like.php b/app/Models/Like.php index 44f25b91..8f2521ef 100644 --- a/app/Models/Like.php +++ b/app/Models/Like.php @@ -18,6 +18,13 @@ class Like extends Model use FilterHtml; use HasFactory; + protected function uri(): Attribute + { + return Attribute::get( + get: fn () => config('app.url').'/likes/'.$this->id, + ); + } + protected function url(): Attribute { return Attribute::set( diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index 192c50f7..044127ce 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -25,6 +25,7 @@ class EntryData extends MicropubData public readonly mixed $syndication, public readonly ?array $photos, public readonly ?string $postStatus, + public readonly ?array $category, ) {} public static function fromRequest(Request $request): static @@ -51,6 +52,7 @@ class EntryData extends MicropubData syndication: Arr::get($data, 'properties.syndication.0'), photos: Arr::get($data, 'properties.photo'), postStatus: Arr::get($data, 'properties.post-status.0'), + category: Arr::get($data, 'properties.category'), ); } @@ -62,17 +64,27 @@ class EntryData extends MicropubData location: $request->input('location'), bookmarkOf: $request->input('bookmark-of'), likeOf: $request->input('like-of'), - mpSyndicateTo: $request->input('mp-syndicate-to'), + mpSyndicateTo: self::wrapFormValue($request->input('mp-syndicate-to')), name: $request->input('name'), description: $request->input('description'), geo: $request->input('geo'), checkin: $request->input('checkin'), syndication: $request->input('syndication'), - photos: $request->input('photos'), + photos: self::wrapFormValue($request->input('photo')), postStatus: $request->input('post-status'), + category: self::wrapFormValue($request->input('category')), ); } + /** + * Form-encoded multi-value properties may be sent with or without the [] + * suffix, so a single value arrives as a string rather than an array. + */ + private static function wrapFormValue(mixed $value): ?array + { + return $value === null ? null : Arr::wrap($value); + } + private static function extractLocationData(array $data): array|string|null { if (! Arr::has($data, 'properties.location')) { @@ -104,6 +116,7 @@ class EntryData extends MicropubData syndication: $data['syndication'] ?? null, photos: $data['photos'] ?? null, postStatus: $data['post-status'] ?? null, + category: $data['category'] ?? null, ); } @@ -125,6 +138,7 @@ class EntryData extends MicropubData 'syndication' => $this->syndication, 'photos' => $this->photos, 'post-status' => $this->postStatus, + 'category' => $this->category, ]; } } diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index d79a0418..6b68b88c 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -35,7 +35,7 @@ class EntryHandler implements MicropubHandlerInterface $dataArray = $data->toArray(); $location = match (true) { - isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, + isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->uri, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri, default => resolve(NoteService::class)->create($dataArray)->uri, diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 136a0840..5515444d 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -9,12 +9,34 @@ use App\Exceptions\MicropubHandlerException; use App\Exceptions\MicropubUnsupportedModelException; use App\Models\Media; use App\Models\Note; +use App\Models\SyndicationTarget; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\Data\UpdateData; +use App\Services\Micropub\NoteResolver; use Illuminate\Support\Str; class UpdateHandler implements MicropubHandlerInterface { + /** + * The properties each update operation can change. + */ + private const SUPPORTED_PROPERTIES = [ + 'replace' => ['content', 'syndication'], + 'add' => ['syndication', 'photo'], + 'delete' => ['syndication', 'photo'], + ]; + + /** + * The syndication URLs we have somewhere to store. Mastodon URLs are also + * accepted, based on the instances in our syndication targets. + */ + private const SYNDICATION_PREFIXES = [ + 'https://www.facebook.com', + 'https://www.swarmapp.com', + 'https://twitter.com', + 'https://bsky.app', + ]; + public function dataClass(): string { return UpdateData::class; @@ -36,24 +58,23 @@ class UpdateHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - $urlPath = parse_url($data->updateUrl, PHP_URL_PATH); - - if (mb_substr($urlPath, 1, 5) !== 'notes') { - throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes'); + if ($data->updateUrl === null) { + throw new MicropubHandlerException('The url parameter is required'); } - $note = Note::nb60(basename($urlPath))->firstOrFail(); + $note = resolve(NoteResolver::class)->fromUrl($data->updateUrl); - if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) { + if (! $data->updateReplace && ! $data->updateAdd && ! $data->updateDelete) { throw new MicropubHandlerException('Unsupported update operation'); } + $this->validateOperations($data); + if ($data->updateReplace !== null) { foreach ($data->updateReplace as $property => $value) { match ($property) { 'content' => $note->note = is_array($value[0]) ? ($value[0]['html'] ?? $value[0]['value'] ?? null) : $value[0], 'syndication' => $this->applySyndication($note, $value), - default => null, }; } } @@ -65,13 +86,11 @@ class UpdateHandler implements MicropubHandlerInterface } if ($property === 'photo') { foreach ($value as $photoURL) { - if (Str::startsWith($photoURL, 'https://')) { - $media = new Media; - $media->path = $photoURL; - $media->type = 'image'; - $media->save(); - $note->media()->save($media); - } + $media = new Media; + $media->path = $photoURL; + $media->type = 'image'; + $media->save(); + $note->media()->save($media); } } } @@ -89,6 +108,77 @@ class UpdateHandler implements MicropubHandlerInterface ]; } + /** + * Check the whole request before applying any of it, so that a bad value + * can't leave a note partially updated, and a client is never told an + * update worked when we ignored it. The spec requires the values of each + * property to be arrays. + * + * @throws MicropubHandlerException + */ + private function validateOperations(UpdateData $data): void + { + foreach (['replace' => $data->updateReplace ?? [], 'add' => $data->updateAdd ?? []] as $operation => $properties) { + foreach ($properties as $property => $values) { + if (! is_string($property) || ! is_array($values) || $values === [] || ! array_is_list($values)) { + throw new MicropubHandlerException('The values of each property must be a non-empty array'); + } + + $this->validateProperty($operation, $property, $values); + } + } + + $delete = $data->updateDelete ?? []; + + if (array_is_list($delete)) { + foreach ($delete as $property) { + if (! is_string($property)) { + throw new MicropubHandlerException('The properties to delete must be strings'); + } + + $this->validateProperty('delete', $property, []); + } + + return; + } + + foreach ($delete as $property => $values) { + if (! is_array($values) || ! array_is_list($values)) { + throw new MicropubHandlerException('The values of each property must be an array'); + } + + $this->validateProperty('delete', $property, $values); + } + } + + /** + * @throws MicropubHandlerException + */ + private function validateProperty(string $operation, string $property, array $values): void + { + if (! in_array($property, self::SUPPORTED_PROPERTIES[$operation], true)) { + throw new MicropubHandlerException("This server does not support the {$operation} operation on the {$property} property"); + } + + foreach ($values as $value) { + if ($property === 'content' && is_array($value)) { + $value = $value['html'] ?? $value['value'] ?? null; + } + + if (! is_string($value)) { + throw new MicropubHandlerException("The {$property} property has an invalid value"); + } + + if ($property === 'syndication' && ! Str::startsWith($value, [...self::SYNDICATION_PREFIXES, ...$this->mastodonPrefixes()])) { + throw new MicropubHandlerException("This server can not store the syndication URL {$value}"); + } + + if ($operation === 'add' && $property === 'photo' && ! Str::startsWith($value, 'https://')) { + throw new MicropubHandlerException('Photos must be added using an https URL'); + } + } + } + private function applyDelete(Note $note, array $delete): void { if (array_is_list($delete)) { @@ -96,7 +186,6 @@ class UpdateHandler implements MicropubHandlerInterface match ($property) { 'syndication' => $this->clearSyndication($note), 'photo' => $note->media()->delete(), - default => null, }; } @@ -113,11 +202,28 @@ class UpdateHandler implements MicropubHandlerInterface } } + /** + * Mastodon can be on any host, so use the instances we syndicate to. + * + * @return string[] + */ + private function mastodonPrefixes(): array + { + return SyndicationTarget::where('service_name', 'Mastodon') + ->pluck('uid') + ->map(fn (string $uid) => parse_url($uid, PHP_URL_HOST)) + ->filter() + ->map(fn (string $host) => "https://{$host}/") + ->all(); + } + private function clearSyndication(Note $note): void { $note->facebook_url = null; $note->swarm_url = null; $note->tweet_id = null; + $note->mastodon_url = null; + $note->bluesky_url = null; } private function removeSyndicationValues(Note $note, array $urls): void @@ -129,6 +235,10 @@ class UpdateHandler implements MicropubHandlerInterface $note->swarm_url = null; } elseif (Str::startsWith($url, 'https://twitter.com')) { $note->tweet_id = null; + } elseif (Str::startsWith($url, $this->mastodonPrefixes()) && $note->mastodon_url === $url) { + $note->mastodon_url = null; + } elseif (Str::startsWith($url, 'https://bsky.app') && $note->bluesky_url === $url) { + $note->bluesky_url = null; } } } @@ -142,6 +252,10 @@ class UpdateHandler implements MicropubHandlerInterface $note->swarm_url = $url; } elseif (Str::startsWith($url, 'https://twitter.com')) { $note->tweet_id = basename(parse_url($url, PHP_URL_PATH)); + } elseif (Str::startsWith($url, $this->mastodonPrefixes())) { + $note->mastodon_url = $url; + } elseif (Str::startsWith($url, 'https://bsky.app')) { + $note->bluesky_url = $url; } } } diff --git a/app/Services/Micropub/MicropubHandlerRegistry.php b/app/Services/Micropub/MicropubHandlerRegistry.php index be393bf5..e6438fa9 100644 --- a/app/Services/Micropub/MicropubHandlerRegistry.php +++ b/app/Services/Micropub/MicropubHandlerRegistry.php @@ -51,7 +51,7 @@ class MicropubHandlerRegistry public function getHandler(string $type): MicropubHandlerInterface { if (! isset($this->handlers[$type])) { - throw new MicropubHandlerException("No handler registered for '{$type}'"); + throw new MicropubHandlerException("Unsupported Micropub request type '{$type}'"); } return $this->handlers[$type]; diff --git a/app/Services/Micropub/NoteResolver.php b/app/Services/Micropub/NoteResolver.php new file mode 100644 index 00000000..26d8d39a --- /dev/null +++ b/app/Services/Micropub/NoteResolver.php @@ -0,0 +1,43 @@ +firstOrFail(); + } catch (NotFoundHttpException) { + // Note::scopeNb60() aborts when the ID is too big for the database + throw (new ModelNotFoundException)->setModel(Note::class); + } + } +} diff --git a/app/Services/Micropub/NoteSource.php b/app/Services/Micropub/NoteSource.php new file mode 100644 index 00000000..e0360e8b --- /dev/null +++ b/app/Services/Micropub/NoteSource.php @@ -0,0 +1,68 @@ +properties($note); + + if ($requested !== []) { + return ['properties' => (object) array_intersect_key($properties, array_flip($requested))]; + } + + return ['type' => ['h-entry'], 'properties' => (object) $properties]; + } + + private function properties(Note $note): array + { + $media = $note->media; + + return array_filter([ + // the raw source, as the note accessor returns the rendered HTML + 'content' => array_filter([$note->getRawOriginal('note')], fn ($value) => $value !== null), + 'published' => [$note->created_at->toIso8601String()], + 'in-reply-to' => array_filter([$note->in_reply_to]), + 'category' => $note->tags->pluck('tag')->all(), + 'photo' => $media->where('type', 'image')->map(fn ($item) => $item->pivot->alt_text + ? ['value' => $item->url, 'alt' => $item->pivot->alt_text] + : $item->url)->values()->all(), + 'video' => $media->where('type', 'video')->pluck('url')->all(), + 'audio' => $media->where('type', 'audio')->pluck('url')->all(), + 'syndication' => array_values(array_filter([ + $note->tweet_id ? 'https://twitter.com/i/web/status/'.$note->tweet_id : null, + $note->facebook_url, + $note->swarm_url, + $note->mastodon_url, + $note->bluesky_url, + ])), + 'location' => $this->location($note), + ], fn (array $values) => $values !== []); + } + + private function location(Note $note): array + { + if ($note->place !== null) { + return [$note->place->uri]; + } + + if ($note->getRawOriginal('location') !== null) { + return ['geo:'.$note->latitude.','.$note->longitude]; + } + + return []; + } +} diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index 2fbe45a2..87c4007f 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -30,6 +30,9 @@ class NoteService $content = null; } + // an invalid date throws, so parse it before anything is saved + $published = $this->getPublished($data); + $note = Note::create( [ 'note' => $content, @@ -38,7 +41,7 @@ class NoteService ] ); - if ($published = $this->getPublished($data)) { + if ($published !== null) { $note->created_at = $note->updated_at = $published; } @@ -205,11 +208,11 @@ class NoteService if (is_string($photo)) { $photoUrl = $photo; } elseif (is_array($photo)) { - $photoUrl = $photo['value']; - $photoAlt = $photo['alt']; + $photoUrl = $photo['value'] ?? null; + $photoAlt = is_string($photo['alt'] ?? null) ? $photo['alt'] : null; } - if (empty($photoUrl)) { + if (! is_string($photoUrl) || $photoUrl === '') { continue; } diff --git a/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php b/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php new file mode 100644 index 00000000..e4f73202 --- /dev/null +++ b/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php @@ -0,0 +1,28 @@ +dropColumn('shorturl'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notes', function (Blueprint $table) { + $table->string('shorturl', 20)->nullable(); + }); + } +}; diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 297c3aa1..f1b9a1a9 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1,2 +1,2 @@ -(()=>{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{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();})(); +(()=>{var l=class{constructor(){}async register(){let e=await this.getCreateOptions(),t={challenge:this.base64URLStringToBuffer(e.challenge),rp:{id:e.rp.id,name:e.rp.name},user:{id:new TextEncoder().encode(window.atob(e.user.id)),name:e.user.name,displayName:e.user.displayName},pubKeyCredParams:e.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:e.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:t});if(!r)throw new Error("Error generating a passkey");let n={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(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 e=await this.getLoginData(),t=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(e.challenge),userVerification:e.userVerification,timeout:6e4}});if(!t)throw new Error("Authentication failed");let r={id:t.id?t.id:"",type:t.type?t.type:"",rawId:t.rawId?this.bufferToBase64URLString(t.rawId):"",response:{authenticatorData:t.response.authenticatorData?this.bufferToBase64URLString(t.response.authenticatorData):"",clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):"",signature:t.response.signature?this.bufferToBase64URLString(t.response.signature):"",userHandle:t.response.userHandle?this.bufferToBase64URLString(t.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(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),s=window.atob(n),i=new ArrayBuffer(s.length),o=new Uint8Array(i);for(let c=0;c{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(e){let t=e==="light"||e==="dark"?e:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",n=document.documentElement,s=n.style.getPropertyValue("color-scheme");s===""&&(s="light dark");let i=!1;t!==s&&(t==="light dark"?i=s!==r:s==="light dark"?i=t!==r:i=!0);let o=()=>{this.currentTheme=e,n.style.setProperty("color-scheme",t),a.storeTheme(e),this.updateButtons(),this.status.textContent=e==="system"?"Theme set to system default":""};n.style.viewTransitionName="changing-theme",i&&document.startViewTransition?document.startViewTransition(o):o()}};var d=new l;document.querySelectorAll(".add-passkey").forEach(a=>{a.addEventListener("click",()=>{d.register()})});document.querySelectorAll(".login-passkey").forEach(a=>{a.addEventListener("click",()=>{d.login()})});var u=new h;u.setupEventListeners();})(); //# sourceMappingURL=app.js.map diff --git a/public/assets/js/app.js.br b/public/assets/js/app.js.br index 7567f865..b2496391 100644 Binary files a/public/assets/js/app.js.br and b/public/assets/js/app.js.br differ diff --git a/public/assets/js/app.js.map b/public/assets/js/app.js.map index a2fb0730..e5228e3e 100644 --- a/public/assets/js/app.js.map +++ b/public/assets/js/app.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../../../resources/js/auth.js", "../../../resources/js/theme-selector.js", "../../../resources/js/app.js"], - "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n this.btnLight = this.widget.querySelector('#btn-light');\n this.btnDark = this.widget.querySelector('#btn-dark');\n this.status = this.widget.querySelector('#theme-status');\n this.currentTheme = 'system';\n }\n\n setupEventListeners() {\n this.btnLight.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');\n });\n\n this.btnDark.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');\n });\n }\n\n applyTheme(theme) {\n const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.documentElement;\n let currentColorScheme = html.style.getPropertyValue('color-scheme');\n if (currentColorScheme === '') {\n currentColorScheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentColorScheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentColorScheme !== systemTheme;\n } else if (currentColorScheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n const applyChange = () => {\n this.currentTheme = theme;\n html.style.setProperty('color-scheme', selectedTheme);\n\n this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));\n this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));\n this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';\n };\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(applyChange);\n } else {\n applyChange();\n }\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], - "mappings": "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", - "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "theme", "selectedTheme", "systemTheme", "html", "currentColorScheme", "doTransition", "applyChange", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] + "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n this.btnLight = this.widget.querySelector('#btn-light');\n this.btnDark = this.widget.querySelector('#btn-dark');\n this.status = this.widget.querySelector('#theme-status');\n this.currentTheme = ThemeSelector.storedTheme();\n this.updateButtons();\n }\n\n static storedTheme() {\n try {\n const theme = localStorage.getItem('theme');\n return (theme === 'light' || theme === 'dark') ? theme : 'system';\n } catch {\n return 'system';\n }\n }\n\n static storeTheme(theme) {\n try {\n if (theme === 'system') {\n localStorage.removeItem('theme');\n } else {\n localStorage.setItem('theme', theme);\n }\n } catch {\n /* storage unavailable, the choice just won't persist */\n }\n }\n\n updateButtons() {\n this.btnLight.setAttribute('aria-pressed', String(this.currentTheme === 'light'));\n this.btnDark.setAttribute('aria-pressed', String(this.currentTheme === 'dark'));\n }\n\n setupEventListeners() {\n this.btnLight.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');\n });\n\n this.btnDark.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');\n });\n }\n\n applyTheme(theme) {\n const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.documentElement;\n let currentColorScheme = html.style.getPropertyValue('color-scheme');\n if (currentColorScheme === '') {\n currentColorScheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentColorScheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentColorScheme !== systemTheme;\n } else if (currentColorScheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n const applyChange = () => {\n this.currentTheme = theme;\n html.style.setProperty('color-scheme', selectedTheme);\n ThemeSelector.storeTheme(theme);\n\n this.updateButtons();\n this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';\n };\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(applyChange);\n } else {\n applyChange();\n }\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], + "mappings": "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", + "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "_ThemeSelector", "theme", "selectedTheme", "systemTheme", "html", "currentColorScheme", "doTransition", "applyChange", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] } diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst index f9a71170..ae85c89d 100644 Binary files a/public/assets/js/app.js.zst and b/public/assets/js/app.js.zst differ diff --git a/resources/js/theme-selector.js b/resources/js/theme-selector.js index 374fdc8c..582a7b4c 100644 --- a/resources/js/theme-selector.js +++ b/resources/js/theme-selector.js @@ -4,7 +4,34 @@ class ThemeSelector { this.btnLight = this.widget.querySelector('#btn-light'); this.btnDark = this.widget.querySelector('#btn-dark'); this.status = this.widget.querySelector('#theme-status'); - this.currentTheme = 'system'; + this.currentTheme = ThemeSelector.storedTheme(); + this.updateButtons(); + } + + static storedTheme() { + try { + const theme = localStorage.getItem('theme'); + return (theme === 'light' || theme === 'dark') ? theme : 'system'; + } catch { + return 'system'; + } + } + + static storeTheme(theme) { + try { + if (theme === 'system') { + localStorage.removeItem('theme'); + } else { + localStorage.setItem('theme', theme); + } + } catch { + /* storage unavailable, the choice just won't persist */ + } + } + + updateButtons() { + this.btnLight.setAttribute('aria-pressed', String(this.currentTheme === 'light')); + this.btnDark.setAttribute('aria-pressed', String(this.currentTheme === 'dark')); } setupEventListeners() { @@ -43,9 +70,9 @@ class ThemeSelector { const applyChange = () => { this.currentTheme = theme; html.style.setProperty('color-scheme', selectedTheme); + ThemeSelector.storeTheme(theme); - this.btnLight.setAttribute('aria-pressed', String(theme === 'light')); - this.btnDark.setAttribute('aria-pressed', String(theme === 'dark')); + this.updateButtons(); this.status.textContent = theme === 'system' ? 'Theme set to system default' : ''; }; diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 4f3303ab..cbac614e 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -25,6 +25,15 @@ @endif @stack('head') +
diff --git a/resources/views/templates/like.blade.php b/resources/views/templates/like.blade.php index 5169a31e..2a114761 100644 --- a/resources/views/templates/like.blade.php +++ b/resources/views/templates/like.blade.php @@ -20,7 +20,7 @@ @endisset diff --git a/routes/web.php b/routes/web.php index a9b46407..d7e1ee2f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -211,14 +211,14 @@ Route::get('note/{id}', [NotesController::class, 'redirect']); // for legacy not // Likes Route::prefix('likes')->group(function () { Route::get('/', [LikesController::class, 'index']); - Route::get('/{like}', [LikesController::class, 'show']); + Route::get('/{like}', [LikesController::class, 'show'])->whereNumber('like'); }); // Bookmarks Route::prefix('bookmarks')->group(function () { Route::get('/', [BookmarksController::class, 'index']); Route::redirect('/tagged', '/bookmarks'); - Route::get('/{bookmark}', [BookmarksController::class, 'show']); + Route::get('/{bookmark}', [BookmarksController::class, 'show'])->whereNumber('bookmark'); Route::get('/tagged/{tag}', [BookmarksController::class, 'tagged']); }); diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index 7b741033..5c4db0d8 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -32,6 +32,13 @@ class BookmarksTest extends TestCase $response->assertViewIs('bookmarks.show'); } + #[Test] + public function non_numeric_bookmark_id_gives_not_found_response(): void + { + $response = $this->get('/bookmarks/twcamp^share|twsrc^ios'); + $response->assertNotFound(); + } + #[Test] public function when_bookmark_is_added_using_http_syntax_check_job_to_take_screenshot_is_invoked(): void { @@ -85,10 +92,10 @@ class BookmarksTest extends TestCase } #[Test] - public function bookmark_local_uri_attribute_returns_correct_url(): void + public function bookmark_uri_attribute_returns_correct_url(): void { $bookmark = Bookmark::factory()->create(); - $this->assertEquals(config('app.url').'/bookmarks/'.$bookmark->id, $bookmark->local_uri); + $this->assertEquals(config('app.url').'/bookmarks/'.$bookmark->id, $bookmark->uri); } #[Test] @@ -109,6 +116,26 @@ class BookmarksTest extends TestCase $response->assertJson(['response' => 'created']); Queue::assertPushed(ProcessBookmark::class); - $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); + $bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail(); + $this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all()); + } + + #[Test] + public function form_encoded_bookmark_with_categories_creates_tags(): void + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->getToken(), + ])->post('/api/post', [ + 'h' => 'entry', + 'bookmark-of' => 'https://example.org/blog-post', + 'category' => ['tag1', 'tag2'], + ]); + + $response->assertJson(['response' => 'created']); + + $bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail(); + $this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all()); } } diff --git a/tests/Feature/LikesTest.php b/tests/Feature/LikesTest.php index 2bda91f3..b08f0d37 100644 --- a/tests/Feature/LikesTest.php +++ b/tests/Feature/LikesTest.php @@ -178,4 +178,11 @@ class LikesTest extends TestCase $response = $this->get('/likes/202'); $response->assertNotFound(); } + + #[Test] + public function non_numeric_like_id_gives_not_found_response(): void + { + $response = $this->get('/likes/twcamp^share|twsrc^ios'); + $response->assertNotFound(); + } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 3fd8b515..2e33ff90 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,11 +4,12 @@ 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\Bookmark; +use App\Models\Like; use App\Models\Media; use App\Models\Note; use App\Models\Place; @@ -39,7 +40,7 @@ class MicropubControllerTest extends TestCase public function micropub_get_request_without_valid_token_returns_error_response(): void { $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer abc123']); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -201,7 +202,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJson(['error' => 'invalid_token']); } @@ -231,7 +232,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJson(['error' => 'insufficient_scope']); } @@ -454,16 +455,13 @@ class MicropubControllerTest extends TestCase 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', ]) - ->assertStatus(401); + ->assertStatus(403); } #[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)); + $this->mock(ExceptionHandler::class)->shouldNotReceive('report'); $response = $this->postJson( '/api/post', @@ -477,10 +475,10 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'unsupported_operation', - 'error_description' => 'The request could not be processed by this server', + 'error' => 'invalid_request', + 'error_description' => "Unsupported Micropub request type 'unsupported'", ]) - ->assertStatus(500); + ->assertStatus(400); } #[Test] @@ -635,8 +633,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'invalid']) - ->assertStatus(500); + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); } #[Test] @@ -674,8 +672,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'unsupported_operation']) - ->assertStatus(500); + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); } #[Test] @@ -693,7 +691,7 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); $response - ->assertStatus(401) + ->assertStatus(403) ->assertJson(['error' => 'insufficient_scope']); } @@ -883,6 +881,50 @@ class MicropubControllerTest extends TestCase $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location')); } + #[Test] + public function micropub_client_api_request_for_a_like_returns_our_permalink_in_the_location_header(): void + { + Queue::fake(); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'like-of' => ['https://example.org/liked-post'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $like = Like::where('url', 'https://example.org/liked-post')->sole(); + $response + ->assertStatus(201) + ->assertHeader('Location', config('app.url').'/likes/'.$like->id); + } + + #[Test] + public function micropub_client_api_request_for_a_bookmark_returns_our_permalink_in_the_location_header(): void + { + Queue::fake(); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'bookmark-of' => ['https://example.org/bookmarked-page'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $bookmark = Bookmark::where('url', 'https://example.org/bookmarked-page')->sole(); + $response + ->assertStatus(201) + ->assertHeader('Location', config('app.url').'/bookmarks/'.$bookmark->id); + } + #[Test] public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void { @@ -972,4 +1014,365 @@ class MicropubControllerTest extends TestCase ->assertStatus(400); $this->assertSame(1, Article::where('title', 'WireGuard')->count()); } + + #[Test] + public function micropub_client_web_request_without_h_property_creates_note(): void + { + $response = $this->post( + '/api/post', + ['content' => 'A note without an explicit type'], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201)->assertJson(['response' => 'created']); + $this->assertDatabaseHas('notes', ['note' => 'A note without an explicit type']); + } + + #[Test] + public function micropub_client_api_request_without_type_creates_note(): void + { + $response = $this->postJson( + '/api/post', + ['properties' => ['content' => ['A JSON note without an explicit type']]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201)->assertJson(['response' => 'created']); + $this->assertDatabaseHas('notes', ['note' => 'A JSON note without an explicit type']); + } + + #[Test] + public function micropub_client_web_request_can_update_note(): void + { + $note = Note::factory()->create(); + + $response = $this->post( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'replace' => ['content' => ['replaced via form']], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(200)->assertJson(['response' => 'updated']); + $this->assertSame('replaced via form', $note->refresh()->content); + } + + #[Test] + public function micropub_client_web_request_with_unsupported_action_is_not_treated_as_a_new_note(): void + { + $note = Note::factory()->create(); + $noteCount = Note::count(); + + $response = $this->post( + '/api/post', + ['action' => 'delete', 'url' => $note->uri], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function micropub_client_web_request_creates_note_with_photo(): void + { + $response = $this->post( + '/api/post', + [ + 'h' => 'entry', + 'content' => 'A note with a photo', + 'photo' => 'https://example.org/photo.jpg', + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201); + $note = Note::where('note', 'A note with a photo')->firstOrFail(); + $this->assertSame(['https://example.org/photo.jpg'], $note->media->pluck('path')->all()); + } + + #[Test] + public function micropub_client_web_request_accepts_single_syndication_target_without_brackets(): void + { + Queue::fake(); + SyndicationTarget::factory()->create([ + 'uid' => 'https://mastodon.social/@jonnybarnes', + 'service_name' => 'Mastodon', + ]); + + $response = $this->post( + '/api/post', + [ + 'h' => 'entry', + 'content' => 'Syndicate me', + 'mp-syndicate-to' => 'https://mastodon.social/@jonnybarnes', + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201); + Queue::assertPushed(SyndicateNoteToMastodon::class); + } + + #[Test] + public function source_query_returns_the_raw_note_as_microformats2(): void + { + $note = Note::factory()->create([ + 'note' => 'Some *markdown* #indieweb', + 'in_reply_to' => 'https://example.org/post', + 'mastodon_url' => 'https://mastodon.example/@me/1', + ]); + $media = Media::factory()->create(['path' => 'media/alt.jpg', 'type' => 'image']); + $note->media()->attach($media->id, ['alt_text' => 'A description', 'order' => 0]); + + $response = $this->getJson( + '/api/post?q=source&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertOk()->assertExactJson([ + 'type' => ['h-entry'], + 'properties' => [ + 'content' => ['Some *markdown* #indieweb'], + 'published' => [$note->created_at->toIso8601String()], + 'in-reply-to' => ['https://example.org/post'], + 'category' => ['indieweb'], + 'photo' => [['value' => $media->url, 'alt' => 'A description']], + 'syndication' => ['https://mastodon.example/@me/1'], + ], + ]); + } + + #[Test] + public function source_query_returns_the_location_of_a_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $geoNote = Note::factory()->create(['location' => '1.23, 4.56']); + $place = Place::factory()->create(); + $placeNote = Note::factory()->create(); + $placeNote->place()->associate($place)->save(); + + $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($geoNote->uri), $headers) + ->assertExactJson(['properties' => ['location' => ['geo:1.23,4.56']]]); + $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($placeNote->uri), $headers) + ->assertExactJson(['properties' => ['location' => [$place->uri]]]); + } + + #[Test] + public function source_query_only_returns_the_requested_properties(): void + { + $note = Note::factory()->create(['note' => 'Hello']); + + $response = $this->getJson( + '/api/post?q=source&properties[]=content&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertOk()->assertExactJson(['properties' => ['content' => ['Hello']]]); + } + + #[Test] + public function source_query_with_no_matching_properties_returns_an_empty_object(): void + { + $note = Note::factory()->create(); + + $response = $this->getJson( + '/api/post?q=source&properties[]=nonsense&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $this->assertSame('{"properties":{}}', $response->assertOk()->getContent()); + } + + #[Test] + public function source_query_reports_invalid_urls(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(); + + $this->getJson('/api/post?q=source', $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode('https://other.example/notes/'.$note->nb60id), $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/blog/A'), $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/ZZZZ'), $headers) + ->assertStatus(404)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/zzzzzzzzzz'), $headers) + ->assertStatus(404)->assertJson(['error' => 'invalid_request']); + } + + #[Test] + public function unknown_and_malformed_queries_return_an_error(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + + foreach (['q=nonsense', 'q[]=config', 'q=geo:junk', 'q=geo:1.2.3,4'] as $query) { + $this->getJson('/api/post?'.$query, $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + } + + #[Test] + public function update_with_a_malformed_value_is_rejected_without_changing_the_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + + $operations = [ + ['replace' => ['content' => 'hello']], + ['replace' => ['content' => []]], + ['replace' => ['content' => [123]]], + ['replace' => ['content' => ['replaced'], 'syndication' => 'https://www.swarmapp.com/1']], + ['replace' => ['content']], + ['add' => ['syndication' => [['nested']]]], + ['delete' => [['content']]], + ['delete' => ['syndication' => 'https://www.swarmapp.com/1']], + ]; + + foreach ($operations as $operation) { + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, ...$operation], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + } + + #[Test] + public function update_that_would_do_nothing_is_rejected_without_changing_the_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + $mediaCount = Media::count(); + + $operations = [ + ['replace' => []], + ['replace' => ['category' => ['indieweb']]], + ['replace' => ['content' => ['changed'], 'category' => ['indieweb']]], + ['replace' => ['photo' => ['https://example.org/photo.jpg']]], + ['replace' => ['syndication' => ['https://unknown.example/1']]], + ['add' => ['category' => ['indieweb']]], + ['add' => ['content' => ['more']]], + ['add' => ['syndication' => ['https://unknown.example/1']]], + ['add' => ['photo' => ['http://example.org/photo.jpg']]], + ['delete' => ['category']], + ['delete' => ['category' => ['indieweb']]], + ['delete' => ['syndication' => ['https://unknown.example/1']]], + ]; + + foreach ($operations as $operation) { + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, ...$operation], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + $this->assertSame($mediaCount, Media::count()); + } + + #[Test] + public function update_can_add_and_delete_mastodon_and_bluesky_syndication_urls(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + SyndicationTarget::factory()->create(['uid' => 'https://mastodon.example/@me', 'service_name' => 'Mastodon']); + $note = Note::factory()->create(); + $mastodon = 'https://mastodon.example/@me/1'; + $bluesky = 'https://bsky.app/profile/jonnybarnes.uk/post/1'; + + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'add' => ['syndication' => [$mastodon, $bluesky]]], $headers) + ->assertOk(); + $note->refresh(); + $this->assertSame($mastodon, $note->mastodon_url); + $this->assertSame($bluesky, $note->bluesky_url); + + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'delete' => ['syndication' => [$mastodon]]], $headers) + ->assertOk(); + $note->refresh(); + $this->assertNull($note->mastodon_url); + $this->assertSame($bluesky, $note->bluesky_url); + + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'delete' => ['syndication']], $headers) + ->assertOk(); + $this->assertNull($note->refresh()->bluesky_url); + } + + #[Test] + public function update_rejects_mastodon_urls_from_instances_we_do_not_syndicate_to(): void + { + SyndicationTarget::factory()->create(['uid' => 'https://mastodon.example/@me', 'service_name' => 'Mastodon']); + SyndicationTarget::factory()->create(['uid' => 'https://other.example/@me', 'service_name' => 'Pixelfed']); + $note = Note::factory()->create(['mastodon_url' => 'https://mastodon.example/@me/1']); + + foreach (['https://mastodon.social/@me/2', 'https://mastodon.example.evil/@me/2', 'https://other.example/@me/2'] as $url) { + $this->postJson( + '/api/post', + ['action' => 'update', 'url' => $note->uri, 'add' => ['syndication' => [$url]]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + + $this->assertSame('https://mastodon.example/@me/1', $note->refresh()->mastodon_url); + } + + #[Test] + public function update_with_an_unusable_url_is_rejected(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + $replace = ['replace' => ['content' => ['changed']]]; + + $this->postJson('/api/post', ['action' => 'update', ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => ['a'], ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => 'https://other.example/notes/'.$note->nb60id, ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => config('app.url').'/notes/zzzzzzzzzz', ...$replace], $headers)->assertStatus(404); + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + } + + #[Test] + public function creating_with_a_wrongly_shaped_property_returns_an_error(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $noteCount = Note::count(); + + $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'mp-syndicate-to' => 'https://a.example']], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => [123]]], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->post('/api/post', ['h' => 'entry', 'content' => ['a', 'b']], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function creating_with_an_invalid_published_date_does_not_leave_a_note_behind(): void + { + Queue::fake(); + $noteCount = Note::count(); + + $this->postJson( + '/api/post', + ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'published' => ['not a date']]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(400)->assertJson(['error' => 'invalid_request']); + + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function creating_with_a_photo_object_without_alt_text_succeeds(): void + { + Queue::fake(); + + $this->postJson( + '/api/post', + ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'photo' => [['value' => 'https://example.org/no-alt.jpg']]]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(201); + + $this->assertDatabaseHas('media_endpoint', ['path' => 'https://example.org/no-alt.jpg']); + } } diff --git a/tests/Feature/MicropubMediaTest.php b/tests/Feature/MicropubMediaTest.php index ef4fcb05..96b6031c 100644 --- a/tests/Feature/MicropubMediaTest.php +++ b/tests/Feature/MicropubMediaTest.php @@ -41,7 +41,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -63,7 +63,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJsonFragment(['error_description' => 'The token’s scope does not have the necessary requirements.']); } @@ -210,14 +210,14 @@ class MicropubMediaTest extends TestCase } #[Test] - public function media_endpoint_request_with_invalid_token_returns400_response(): void + public function media_endpoint_request_with_invalid_token_returns401_response(): void { $response = $this->post( '/api/media', [], ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -234,14 +234,14 @@ class MicropubMediaTest extends TestCase } #[Test] - public function media_endpoint_request_with_insufficient_token_scopes_returns401_response(): void + public function media_endpoint_request_with_insufficient_token_scopes_returns403_response(): void { $response = $this->post( '/api/media', [], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJsonFragment([ 'error_description' => 'The token’s scope does not have the necessary requirements.', ]); diff --git a/tests/Unit/LikesTest.php b/tests/Unit/LikesTest.php index 74412833..ef4c21f2 100644 --- a/tests/Unit/LikesTest.php +++ b/tests/Unit/LikesTest.php @@ -13,6 +13,14 @@ class LikesTest extends TestCase { use RefreshDatabase; + #[Test] + public function like_uri_attribute_returns_our_permalink(): void + { + $like = Like::factory()->create(); + + $this->assertEquals(config('app.url').'/likes/'.$like->id, $like->uri); + } + #[Test] public function we_can_set_the_author_url(): void {