Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40fa08a3c3 | |||
|
eb35a0aa2d |
|||
| be9876a9a7 | |||
|
6727138f43 |
|||
| 72ddfe739d | |||
|
4aa93d63bb |
|||
|
77998a963e |
|||
| 89c083aebe | |||
|
568ae78864 |
18 changed files with 319 additions and 8 deletions
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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' => ($data['post-status'] ?? null) !== 'draft',
|
'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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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.
Binary file not shown.
Binary file not shown.
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
resources/views/admin/tokens/create.blade.php
Normal file
52
resources/views/admin/tokens/create.blade.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
@extends('master')
|
||||||
|
|
||||||
|
@section('title')New Token « Admin CP « @stop
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1>Generate a new token</h1>
|
||||||
|
<p>Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.</p>
|
||||||
|
|
||||||
|
<form action="/admin/tokens" method="post" accept-charset="utf-8" class="admin-form form">
|
||||||
|
{{ csrf_field() }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="client_id">Client</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="client_id"
|
||||||
|
id="client_id"
|
||||||
|
value="{{ old('client_id') }}"
|
||||||
|
placeholder="https://ia.net/writer"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scope-checkboxes">
|
||||||
|
<span>Scope</span>
|
||||||
|
<label for="scope_create">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="scope[]"
|
||||||
|
id="scope_create"
|
||||||
|
value="create"
|
||||||
|
@checked(in_array('create', old('scope', []), true))
|
||||||
|
>
|
||||||
|
create
|
||||||
|
</label>
|
||||||
|
<label for="scope_update">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="scope[]"
|
||||||
|
id="scope_update"
|
||||||
|
value="update"
|
||||||
|
@checked(in_array('update', old('scope', []), true))
|
||||||
|
>
|
||||||
|
update
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" name="save">Generate token</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@stop
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -157,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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
[
|
[
|
||||||
|
|
@ -871,6 +879,8 @@ class MicropubControllerTest extends TestCase
|
||||||
'main' => $content,
|
'main' => $content,
|
||||||
'published' => true,
|
'published' => true,
|
||||||
]);
|
]);
|
||||||
|
$response->assertHeader('Location');
|
||||||
|
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
@ -902,4 +912,64 @@ class MicropubControllerTest extends TestCase
|
||||||
'published' => false,
|
'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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue