Compare commits

..
Author SHA1 Message Date
6e96ca8304
Bring Micropub endpoint closer to the spec
- Detect form-encoded action=... requests, so form-encoded updates work
  and unhandled actions are no longer created as notes
- Default to h-entry when no type is given
- Read form-encoded photos from `photo` rather than `photos`
- Pass `category` through EntryData so bookmark tags are saved
- Accept single values for form-encoded multi-value properties
- Return 403 for insufficient_scope and 401 for invalid tokens
- Return 400 invalid_request for unsupported post types, update
  operations and models instead of reporting them as 500s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:45:39 +00:00
cc2ff2b2a2
Drop the unused notes.shorturl column
The short domain is gone and nothing reads or writes this column any more.

Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:29:05 +00:00
73b9668088
Link to likes through the uri attribute in the like template
Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:29:00 +00:00
e8f2535cd1
Return our permalink in the Micropub Location header for likes
Likes returned `url`, which is the liked post rather than our page. Use
`uri` instead. Bookmarks already asked for `uri`, which didn't exist until
the previous commit, so their Location header was empty.

Neither case had a test, so assert the header value for both.

Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:29:00 +00:00
5481956d78
Use uri for the like and bookmark permalinks
`uri` is the attribute every other postable model uses for its absolute
permalink on this site. Add it to Like, which had none, and rename
Bookmark's `local_uri` to match. `url` stays as the thing being liked or
bookmarked.

Refs #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:28:30 +00:00
7ce256baa5
Return 404 for non-numeric like and bookmark IDs
Constrain the likes/{like} and bookmarks/{bookmark} routes to numeric
IDs. Previously a junk URL such as /likes/twcamp^share|twsrc^ios was
passed to route model binding, and Postgres rejected the text-vs-integer
comparison with a QueryException, returning a 500 instead of a 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:01:28 +00:00
10 changed files with 110 additions and 7 deletions

View file

@ -25,7 +25,7 @@ class Bookmark extends Model
return $this->belongsToMany('App\Models\Tag'); return $this->belongsToMany('App\Models\Tag');
} }
protected function localUri(): Attribute protected function uri(): Attribute
{ {
return Attribute::get( return Attribute::get(
get: fn () => config('app.url').'/bookmarks/'.$this->id, get: fn () => config('app.url').'/bookmarks/'.$this->id,

View file

@ -18,6 +18,13 @@ class Like extends Model
use FilterHtml; use FilterHtml;
use HasFactory; use HasFactory;
protected function uri(): Attribute
{
return Attribute::get(
get: fn () => config('app.url').'/likes/'.$this->id,
);
}
protected function url(): Attribute protected function url(): Attribute
{ {
return Attribute::set( return Attribute::set(

View file

@ -35,7 +35,7 @@ class EntryHandler implements MicropubHandlerInterface
$dataArray = $data->toArray(); $dataArray = $data->toArray();
$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)->uri,
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)->uri, 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,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::table('notes', function (Blueprint $table) {
$table->dropColumn('shorturl');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('notes', function (Blueprint $table) {
$table->string('shorturl', 20)->nullable();
});
}
};

View file

@ -20,7 +20,7 @@
</blockquote> </blockquote>
@endisset @endisset
<div> <div>
<a href="/likes/{{ $like->id }}"><time class="dt-published" datetime="{{ $like->updated_at->toISO8601String() }}" title="{{ $like->updated_at->toISO8601String() }}">{{ $like->updated_at->diffForHumans() }}</time></a> <a href="{{ $like->uri }}"><time class="dt-published" datetime="{{ $like->updated_at->toISO8601String() }}" title="{{ $like->updated_at->toISO8601String() }}">{{ $like->updated_at->diffForHumans() }}</time></a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -211,14 +211,14 @@ Route::get('note/{id}', [NotesController::class, 'redirect']); // for legacy not
// Likes // Likes
Route::prefix('likes')->group(function () { Route::prefix('likes')->group(function () {
Route::get('/', [LikesController::class, 'index']); Route::get('/', [LikesController::class, 'index']);
Route::get('/{like}', [LikesController::class, 'show']); Route::get('/{like}', [LikesController::class, 'show'])->whereNumber('like');
}); });
// Bookmarks // Bookmarks
Route::prefix('bookmarks')->group(function () { Route::prefix('bookmarks')->group(function () {
Route::get('/', [BookmarksController::class, 'index']); Route::get('/', [BookmarksController::class, 'index']);
Route::redirect('/tagged', '/bookmarks'); 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']); Route::get('/tagged/{tag}', [BookmarksController::class, 'tagged']);
}); });

View file

@ -32,6 +32,13 @@ class BookmarksTest extends TestCase
$response->assertViewIs('bookmarks.show'); $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] #[Test]
public function when_bookmark_is_added_using_http_syntax_check_job_to_take_screenshot_is_invoked(): void 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] #[Test]
public function bookmark_local_uri_attribute_returns_correct_url(): void public function bookmark_uri_attribute_returns_correct_url(): void
{ {
$bookmark = Bookmark::factory()->create(); $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] #[Test]

View file

@ -178,4 +178,11 @@ class LikesTest extends TestCase
$response = $this->get('/likes/202'); $response = $this->get('/likes/202');
$response->assertNotFound(); $response->assertNotFound();
} }
#[Test]
public function non_numeric_like_id_gives_not_found_response(): void
{
$response = $this->get('/likes/twcamp^share|twsrc^ios');
$response->assertNotFound();
}
} }

View file

@ -8,6 +8,8 @@ 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\Article;
use App\Models\Bookmark;
use App\Models\Like;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
use App\Models\Place; use App\Models\Place;
@ -879,6 +881,50 @@ class MicropubControllerTest extends TestCase
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location')); $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] #[Test]
public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void
{ {

View file

@ -13,6 +13,14 @@ class LikesTest extends TestCase
{ {
use RefreshDatabase; 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] #[Test]
public function we_can_set_the_author_url(): void public function we_can_set_the_author_url(): void
{ {