Compare commits

..
Author SHA1 Message Date
40fa08a3c3 Merge pull request '[MTM] Update existing draft articles instead of erroring on a repeat Micropub post' (#139) from develop into main
Reviewed-on: #139
2026-09-19 18:21:18 +02:00
eb35a0aa2d
Update existing draft articles instead of erroring on a repeat Micropub post
If a Micropub h-entry post's title matches an existing article that's
still a draft, update that article in place rather than trying to
insert a duplicate. If it matches one that's already published,
reject the request with a clear error instead of silently colliding.

Also set includeTrashed on Article's slug config as a safety net: this
model soft-deletes, and Sluggable's uniqueness check ignores trashed
rows by default, so a previously-deleted article's title could crash
new inserts with a raw unique constraint violation (this is exactly
what surfaced in Flare as a UniqueConstraintViolationException on
articles_titleurl_unique once the prior swallowed-exception fix
shipped).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 17:16:11 +01:00
be9876a9a7 Merge pull request '[MTM] Report exceptions from Micropub 500 error paths instead of swallowing them' (#137) from develop into main
Reviewed-on: #137
2026-09-19 18:02:58 +02:00
6727138f43
Report exceptions from Micropub 500 error paths instead of swallowing them
MicropubController's catch-all handlers returned a generic 500 without
ever calling report(), so failures never reached laravel.log or Flare
(Flare is already wired up via bootstrap/app.php). Widened the final
catch to \Throwable so PHP Errors (e.g. TypeError) get the same
Micropub-shaped error response and are also reported.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:22:33 +01:00
72ddfe739d Merge pull request '[MTM] Fix relative Location header when Micropub creates an article' (#135) from develop into main
Reviewed-on: #135
2026-09-13 13:21:04 +02:00
4aa93d63bb
Add Article::uri accessor for the absolute post URL
Follow the uri/link convention already used by Note, Bookmark, and
Place, rather than concatenating config('app.url') inline where the
absolute URL is needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 12:12:36 +01:00
77998a963e
Fix relative Location header when Micropub creates an article
EntryHandler used Article::link, which is deliberately a site-relative
path elsewhere in the app, directly as the Micropub response's
Location URL. Every other post type it returns (notes, bookmarks,
places) already prepends the site URL, so articles were the only case
where clients received a relative Location - iA Writer appears to
treat that as a local file path and fails to open it after posting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 11:42:28 +01:00
89c083aebe Merge pull request '[MTM] Add manual Micropub token generation for non-PKCE clients' (#133) from develop into main
Reviewed-on: #133
2026-09-13 12:32:28 +02:00
568ae78864
Add manual Micropub token generation for non-PKCE clients
iA Writer's IndieAuth client predates PKCE support in the spec, so it
can't complete the normal authorization flow. Add an admin form to
mint a token directly (reusing the existing TokenService), so it can
be pasted into clients that support manual token setup instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 10:40:27 +01:00
c6de984145 Merge pull request '[MTM] Add admin-editable About page' (#131) from develop into main
Reviewed-on: #131
2026-08-28 16:01:29 +02:00
9e9fbdc127
Add admin-editable About page
Mirrors the existing Bio singleton pattern: a new `about` table/model,
admin CRUD at /admin/about, and a public page at /about linked from
both the header nav and the admin homepage. Content is wrapped in
.e-content so it lays out correctly within the site's CSS grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bzYwaDD8p3XNDMYKvXKrs
2026-08-28 14:49:53 +01:00
483a7f9ded Merge pull request '[MTM] Fronte end tweaks' (#129) from develop into main
Reviewed-on: #129
2026-08-23 11:25:15 +02:00
9532aae37e
Bring back winter snow effect, gated by an admin setting
The site has flip-flopped on the snow effect every winter (add it,
remove it, repeat), meaning a PR round-trip each time. Instead, restore
the winter.js/is-land assets and gate them behind a new admin-toggleable
setting so the effect can be switched on/off from /admin/settings
without touching code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113BVPXDpEk9HPEqXvSG2WQ
2026-08-23 10:17:26 +01:00
e9d8f4c74b
Redesign theme toggle as a two-icon light/dark switch
Replace the single-icon popover with a dropdown of radio inputs with a
compact two-button toggle (light/dark), inspired by vale.rocks' design:
clicking an icon forces that theme, clicking the active icon again
returns to following the system preference. Keeps the existing
light-dark() colour-scheme mechanism and view-transition wipe
animation, and reuses the project's existing Fluent UI sun/moon icons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fn4aNjE3ofpDM78F4qmdCV
2026-08-22 18:14:08 +01:00
be2bc267ad
Remove snow-fall 2026-08-22 17:39:31 +01:00
d6ab4424fc Merge pull request '[MTM] Improve Micropub support for drafts' (#125) from develop into main
Reviewed-on: #125
2026-08-22 18:25:39 +02:00
a76c84b40f
Respect Micropub post-status when creating articles
Articles created via Micropub were always published immediately,
ignoring the client-supplied post-status property. Clients like iA
Writer that publish drafts (post-status: draft) had no way to land
an article as unpublished.

EntryData now extracts post-status from the request, and
ArticleService sets published accordingly, defaulting to true when
the property is omitted or set to anything other than "draft".

Fixes #123

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8
2026-08-22 17:16:55 +01:00
99f306ede1
Replace RSS icon with a JSON Feed icon in the header
The nav's feed link already points at the JSON feed, but was using
the feather RSS icon. Adds an SVG recreation of the official JSON
Feed mark (traced from jsonfeed.org's icon) and swaps it in, using a
single dark-green fill throughout so it stays legible against both
the light and dark theme backgrounds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8
2026-08-22 10:42:23 +01:00
714cd95d79
Drop RSS/Atom feeds, support JSON feeds only
Removes the RSS and Atom feed routes, controller methods, and views
for both the blog and notes feeds, keeping JSON (and JF2) as the only
supported feed formats. Also serves the JSON feeds with the
spec-required application/feed+json MIME type instead of the generic
application/json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8
2026-08-22 10:42:16 +01:00
50 changed files with 957 additions and 361 deletions

View 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,
]);
}
}

View 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');
}
}

View 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');
}
}

View file

@ -6,7 +6,9 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\MicropubToken; use App\Models\MicropubToken;
use App\Services\TokenService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
class TokensController extends Controller class TokensController extends Controller
@ -21,6 +23,36 @@ class TokensController extends Controller
return view('admin.tokens.index', compact('tokens')); 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. * Revoke a Micropub token.
*/ */

View file

@ -7,66 +7,13 @@ namespace App\Http\Controllers;
use App\Models\Article; use App\Models\Article;
use App\Models\Note; use App\Models\Note;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class FeedsController extends Controller 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. * Returns the blog JSON feed.
*/ */
public function blogJson(): array public function blogJson(): JsonResponse
{ {
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$data = [ $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. * Returns the notes JSON feed.
*/ */
public function notesJson(): array public function notesJson(): JsonResponse
{ {
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
$data = [ $data = [
@ -130,7 +79,9 @@ class FeedsController extends Controller
} }
} }
return $data; return response()->json($data, 200, [
'Content-Type' => 'application/feed+json',
]);
} }
/** /**

View file

@ -70,7 +70,9 @@ class MicropubController extends Controller
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => 'No known note with given ID', 'error_description' => 'No known note with given ID',
], 404); ], 404);
} catch (MicropubUnsupportedModelException) { } catch (MicropubUnsupportedModelException $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'invalid', 'error' => 'invalid',
'error_description' => 'This implementation currently only supports the updating of notes', 'error_description' => 'This implementation currently only supports the updating of notes',
@ -80,12 +82,16 @@ class MicropubController extends Controller
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => $e->getMessage(), 'error_description' => $e->getMessage(),
], 400); ], 400);
} catch (MicropubHandlerException) { } catch (MicropubHandlerException $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'unsupported_operation', 'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server', 'error_description' => 'The request could not be processed by this server',
], 500); ], 500);
} catch (\Exception $e) { } catch (\Throwable $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'server_error', 'error' => 'server_error',
'error_description' => 'An error occurred processing the request', 'error_description' => 'An error occurred processing the request',

13
app/Models/About.php Normal file
View 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';
}

View file

@ -40,6 +40,7 @@ class Article extends Model
return [ return [
'titleurl' => [ 'titleurl' => [
'source' => 'title', '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. * Scope a query to only include articles from a particular year/month.
*/ */

18
app/Models/Setting.php Normal file
View 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',
];
}

View file

@ -2,10 +2,12 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
@ -63,5 +65,10 @@ class AppServiceProvider extends ServiceProvider
// Force HTTPS URL generation in production // Force HTTPS URL generation in production
URL::forceHttps($this->app->isProduction()); 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);
});
} }
} }

View file

@ -8,12 +8,29 @@ use App\Models\Article;
class ArticleService class ArticleService
{ {
/**
* @throws \InvalidArgumentException if a published article already has this title
*/
public function create(array $data): Article public function create(array $data): Article
{ {
return Article::create([ $attributes = [
'title' => $data['name'], 'title' => $data['name'],
'main' => $data['content'], '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);
} }
} }

View file

@ -24,6 +24,7 @@ class EntryData extends MicropubData
public readonly mixed $checkin, public readonly mixed $checkin,
public readonly mixed $syndication, public readonly mixed $syndication,
public readonly ?array $photos, public readonly ?array $photos,
public readonly ?string $postStatus,
) {} ) {}
public static function fromRequest(Request $request): static public static function fromRequest(Request $request): static
@ -49,6 +50,7 @@ class EntryData extends MicropubData
checkin: Arr::get($data, 'properties.checkin.0'), checkin: Arr::get($data, 'properties.checkin.0'),
syndication: Arr::get($data, 'properties.syndication.0'), syndication: Arr::get($data, 'properties.syndication.0'),
photos: Arr::get($data, 'properties.photo'), 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'), checkin: $request->input('checkin'),
syndication: $request->input('syndication'), syndication: $request->input('syndication'),
photos: $request->input('photos'), photos: $request->input('photos'),
postStatus: $request->input('post-status'),
); );
} }
@ -100,6 +103,7 @@ class EntryData extends MicropubData
checkin: $data['checkin'] ?? null, checkin: $data['checkin'] ?? null,
syndication: $data['syndication'] ?? null, syndication: $data['syndication'] ?? null,
photos: $data['photos'] ?? null, photos: $data['photos'] ?? null,
postStatus: $data['post-status'] ?? null,
); );
} }
@ -120,6 +124,7 @@ class EntryData extends MicropubData
'checkin' => $this->checkin, 'checkin' => $this->checkin,
'syndication' => $this->syndication, 'syndication' => $this->syndication,
'photos' => $this->photos, 'photos' => $this->photos,
'post-status' => $this->postStatus,
]; ];
} }
} }

View file

@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
$location = match (true) { $location = match (true) {
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, 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, default => resolve(NoteService::class)->create($dataArray)->uri,
}; };

View 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,
];
}
}

View 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,
];
}
}

View 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('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');
}
};

View 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');
}
};

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.

View file

@ -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 //# 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.

View file

@ -134,4 +134,30 @@
.token-list button.revoke:hover { .token-list button.revoke:hover {
background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg)); 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;
}
} }

View file

@ -1,70 +1,66 @@
@layer components { @layer components {
#theme-selector { #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 { button {
display: grid;
place-items: center;
appearance: none; appearance: none;
border: none; border: none;
background: none; border-radius: 999px;
padding: .375rem;
background: transparent;
color: inherit;
transition: background-color .2s, color .2s;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
} }
&:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 2px;
} }
/* This is the element with the [popover] attribute */ svg {
#theme-selector-dropdown { 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: absolute;
position-area: block-end span-inline-start; width: 1px;
margin: 0; height: 1px;
border: 2px solid var(--clr-border); padding: 0;
background-color: var(--clr-background); margin: -1px;
color: var(--clr-text); overflow: hidden;
clip-path: inset(50%);
fieldset { white-space: nowrap;
border: 0; 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) { ::view-transition-group(changing-theme) {

View file

@ -1,69 +1,60 @@
class ThemeSelector { class ThemeSelector {
constructor() { constructor() {
this.widget = document.querySelector('#theme-selector'); 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() { setupEventListeners() {
this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => { this.btnLight.addEventListener('click', () => {
let theme = e.target.value; this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');
// Update current icon
this.widget.querySelectorAll('.toggle svg').forEach((svg) => {
if (svg.classList.contains(theme)) {
svg.style.display = '';
} else {
svg.style.display = 'none';
}
}); });
// Set the theme this.btnDark.addEventListener('click', () => {
let selectedTheme; this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
switch (theme) { });
case 'dark':
case 'light':
selectedTheme = theme;
break;
default:
selectedTheme = 'light 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 systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const html = document.querySelector('html'); const html = document.documentElement;
let currentTheme = html.style.getPropertyValue('color-scheme'); let currentColorScheme = html.style.getPropertyValue('color-scheme');
if (currentTheme === '') { if (currentColorScheme === '') {
currentTheme = 'light dark'; currentColorScheme = 'light dark';
} }
/* do we need to transition */ /* do we need to transition */
let doTransition = false; let doTransition = false;
if (selectedTheme !== currentTheme) { if (selectedTheme !== currentColorScheme) {
if (selectedTheme === 'light dark') { if (selectedTheme === 'light dark') {
doTransition = currentTheme !== systemTheme; doTransition = currentColorScheme !== systemTheme;
} else if (currentTheme === 'light dark') { } else if (currentColorScheme === 'light dark') {
doTransition = selectedTheme !== systemTheme; doTransition = selectedTheme !== systemTheme;
} else { } else {
doTransition = true; 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'; html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) { if (doTransition && document.startViewTransition) {
document.startViewTransition(() => { document.startViewTransition(applyChange);
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
});
} else { } else {
// Close the popover applyChange();
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
} }
}));
} }
} }

View file

@ -0,0 +1,9 @@
@extends('master')
@section('title')About « @stop
@section('content')
<h2>About</h2>
<div class="e-content">
{!! $about !!}
</div>
@stop

View 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

View 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

View 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

View file

@ -4,6 +4,15 @@
@section('content') @section('content')
<h1>Micropub Tokens</h1> <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()) @if($tokens->isEmpty())
<p>No tokens have been issued.</p> <p>No tokens have been issued.</p>
@else @else

View file

@ -57,8 +57,18 @@
Edit your <a href="/admin/bio">bio</a>. Edit your <a href="/admin/bio">bio</a>.
</p> </p>
<h2>About</h2>
<p>
Edit your <a href="/admin/about">about page</a>.
</p>
<h2>Passkeys</h2> <h2>Passkeys</h2>
<p> <p>
Manager <a href="/admin/passkeys">your passkeys</a>. Manager <a href="/admin/passkeys">your passkeys</a>.
</p> </p>
<h2>Settings</h2>
<p>
Edit <a href="/admin/settings">site settings</a>.
</p>
@stop @stop

View file

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

View file

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

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

View file

@ -7,13 +7,9 @@
<title>@yield('title'){{ config('app.name') }}</title> <title>@yield('title'){{ config('app.name') }}</title>
<link rel="stylesheet" href="/assets/highlight/nord.css"> <link rel="stylesheet" href="/assets/highlight/nord.css">
<link rel="stylesheet" href="/assets/css/app.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/feed+json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
<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/jf2feed+json" title="Blog JF2 Feed" href="{{ route('feed.blog.jf2') }}"> <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/feed+json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
<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/jf2feed+json" title="Notes JF2 Feed" href="{{ route('feed.notes.jf2') }}"> <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="indieauth-metadata" href="{{ route('indieauth.metadata') }}">
<link rel="authorization_endpoint" href="{{ route('indieauth.start') }}"> <link rel="authorization_endpoint" href="{{ route('indieauth.start') }}">
@ -40,37 +36,17 @@
<a href="/likes">Likes</a> <a href="/likes">Likes</a>
<a href="/contacts">Contacts</a> <a href="/contacts">Contacts</a>
<a href="/projects">Projects</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> </nav>
<div id="theme-selector"> <div id="theme-selector" role="region" aria-label="Theme switcher">
<button class="toggle" popovertarget="theme-selector-dropdown"> <button id="btn-light" aria-label="Light mode" aria-pressed="false">
<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" 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>
<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>
</button> </button>
<div id="theme-selector-dropdown" popover> <button id="btn-dark" aria-label="Dark mode" aria-pressed="false">
<fieldset> <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>
<legend>Select theme:</legend> </button>
<div> <span id="theme-status" class="sr-only" aria-live="polite"></span>
<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>
</div> </div>
</header> </header>
@ -106,6 +82,7 @@
@section('scripts') @section('scripts')
<script type="module" src="/assets/js/app.js"></script> <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/is-land.min.js"></script>
<script type="module" src="/assets/js/winter.js"></script> <script type="module" src="/assets/js/winter.js"></script>
<is-land on:media="(prefers-reduced-motion: no-preference)"> <is-land on:media="(prefers-reduced-motion: no-preference)">
@ -114,6 +91,7 @@
style="--snow-fall-color: var(--clr-snow-fall)" style="--snow-fall-color: var(--clr-snow-fall)"
></snow-fall> ></snow-fall>
</is-land> </is-land>
@endif
@show @show
</body> </body>
</html> </html>

View file

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

View file

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

View file

@ -1,5 +1,7 @@
<?php <?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\ArticlesController as AdminArticlesController;
use App\Http\Controllers\Admin\BioController; use App\Http\Controllers\Admin\BioController;
use App\Http\Controllers\Admin\ClientsController; use App\Http\Controllers\Admin\ClientsController;
@ -9,6 +11,7 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController;
use App\Http\Controllers\Admin\NotesController as AdminNotesController; use App\Http\Controllers\Admin\NotesController as AdminNotesController;
use App\Http\Controllers\Admin\PasskeysController; use App\Http\Controllers\Admin\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; 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\SyndicationTargetsController;
use App\Http\Controllers\Admin\TokensController; use App\Http\Controllers\Admin\TokensController;
use App\Http\Controllers\ArticlesController; use App\Http\Controllers\ArticlesController;
@ -50,6 +53,9 @@ Route::view('projects', 'projects');
// Static colophon page // Static colophon page
Route::view('colophon', 'colophon'); Route::view('colophon', 'colophon');
// About page
Route::get('about', [AboutPageController::class, 'show']);
// The login routes to get authd for admin // The login routes to get authd for admin
Route::get('login', [AuthController::class, 'showLogin'])->name('login'); Route::get('login', [AuthController::class, 'showLogin'])->name('login');
Route::post('login', [AuthController::class, 'login']); Route::post('login', [AuthController::class, 'login']);
@ -151,6 +157,8 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
// Micropub Tokens // Micropub Tokens
Route::prefix('tokens')->group(function () { Route::prefix('tokens')->group(function () {
Route::get('/', [TokensController::class, 'index']); Route::get('/', [TokensController::class, 'index']);
Route::get('/create', [TokensController::class, 'create']);
Route::post('/', [TokensController::class, 'store']);
Route::put('/{token}/revoke', [TokensController::class, 'revoke']); Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
}); });
@ -160,6 +168,18 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::put('/', [BioController::class, 'update']); 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 // Passkeys
Route::prefix('passkeys')->group(function () { Route::prefix('passkeys')->group(function () {
Route::get('/', [PasskeysController::class, 'index']); Route::get('/', [PasskeysController::class, 'index']);
@ -170,8 +190,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
// Blog pages using ArticlesController // Blog pages using ArticlesController
Route::prefix('blog')->group(function () { 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.json', [FeedsController::class, 'blogJson'])->name('feed.blog.json');
Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2'); Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2');
Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']); Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']);
@ -182,8 +200,6 @@ Route::prefix('blog')->group(function () {
// Notes pages using NotesController // Notes pages using NotesController
Route::prefix('notes')->group(function () { Route::prefix('notes')->group(function () {
Route::get('/', [NotesController::class, 'index']); 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.json', [FeedsController::class, 'notesJson'])->name('feed.notes.json');
Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2'); Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2');
Route::get('/new', [NotesController::class, 'create']); Route::get('/new', [NotesController::class, 'create']);

View 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.');
}
}

View 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',
]);
}
}

View 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);
}
}

View file

@ -37,6 +37,73 @@ class TokensTest extends TestCase
$response->assertSeeText($token->client_id); $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] #[Test]
public function revoke_requires_authentication(): void public function revoke_requires_authentication(): void
{ {

View file

@ -15,42 +15,6 @@ class FeedsTest extends TestCase
{ {
use RefreshDatabase; 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] #[Test]
public function blog_jf2_feed_is_present(): void 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. * Test the blog JSON feed.
*/ */
@ -93,7 +45,7 @@ class FeedsTest extends TestCase
{ {
Article::factory()->count(3)->create(); Article::factory()->count(3)->create();
$response = $this->get('/blog/feed.json'); $response = $this->get('/blog/feed.json');
$response->assertHeader('Content-Type', 'application/json'); $response->assertHeader('Content-Type', 'application/feed+json');
$response->assertOk(); $response->assertOk();
} }
@ -105,7 +57,7 @@ class FeedsTest extends TestCase
{ {
Note::factory()->count(3)->create(); Note::factory()->count(3)->create();
$response = $this->get('/notes/feed.json'); $response = $this->get('/notes/feed.json');
$response->assertHeader('Content-Type', 'application/json'); $response->assertHeader('Content-Type', 'application/feed+json');
$response->assertOk(); $response->assertOk();
} }

View file

@ -4,14 +4,17 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Exceptions\MicropubHandlerException;
use App\Jobs\SendWebMentions; use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon; use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Article;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
use App\Models\Place; use App\Models\Place;
use App\Models\SyndicationTarget; use App\Models\SyndicationTarget;
use Faker\Factory; use Faker\Factory;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -457,6 +460,11 @@ class MicropubControllerTest extends TestCase
#[Test] #[Test]
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void 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( $response = $this->postJson(
'/api/post', '/api/post',
[ [
@ -869,6 +877,99 @@ class MicropubControllerTest extends TestCase
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [
'title' => $name, 'title' => $name,
'main' => $content, '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());
}
} }

View file

@ -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] #[Test]
public function date_scope_returns_expected_articles(): void public function date_scope_returns_expected_articles(): void
{ {