move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
<?php
return ['prefix' => 'api/v1', 'token_prefix' => 'cms_', 'pagination' => 20, 'webhook_timeout' => 10, 'openapi' => ['title' => env('APP_NAME', 'Content Manager').' API', 'version' => '1.0.0']];
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('api_tokens', function (Blueprint $t): void {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->string('name');
$t->string('token', 64)->unique();
$t->json('abilities')->nullable();
$t->timestamp('last_used_at')->nullable();
$t->timestamp('expires_at')->nullable();
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('api_tokens');
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhooks', function (Blueprint $t): void {
$t->id();
$t->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$t->string('name');
$t->string('url');
$t->string('secret');
$t->json('events');
$t->boolean('is_active')->default(true);
$t->unsignedSmallInteger('failure_count')->default(0);
$t->timestamp('last_triggered_at')->nullable();
$t->timestamps();
});
Schema::create('webhook_deliveries', function (Blueprint $t): void {
$t->id();
$t->foreignId('webhook_id')->constrained()->cascadeOnDelete();
$t->uuid('uuid')->unique();
$t->string('event');
$t->json('payload');
$t->unsignedSmallInteger('response_status')->nullable();
$t->text('response_body')->nullable();
$t->unsignedSmallInteger('attempts')->default(0);
$t->timestamp('delivered_at')->nullable();
$t->timestamp('failed_at')->nullable();
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webhook_deliveries');
Schema::dropIfExists('webhooks');
}
};
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\Api\Models\ApiToken;
use Modules\Api\Models\Webhook;
final class ApiController
{
public function index(): View
{
return view('api::admin.index', ['tokens' => ApiToken::with('user')->latest()->paginate(20, '*', 'tokens'), 'webhooks' => Webhook::latest()->paginate(20, '*', 'webhooks')]);
}
public function storeToken(Request $r): RedirectResponse
{
$d = $r->validate(['name' => 'required|string|max:255', 'abilities' => 'nullable|string', 'expires_at' => 'nullable|date|after:now']);
$abilities = array_values(array_filter(array_map('trim', explode(',', $d['abilities'] ?: '*'))));
[,$plain] = ApiToken::issue($r->user(), $d['name'], $abilities, isset($d['expires_at']) ? new \DateTimeImmutable($d['expires_at']) : null);
return back()->with('api_token', $plain)->with('status', 'API token created.');
}
public function destroyToken(ApiToken $token): RedirectResponse
{
$token->delete();
return back()->with('status', 'API token revoked.');
}
public function storeWebhook(Request $r): RedirectResponse
{
$d = $r->validate(['name' => 'required|string|max:255', 'url' => 'required|url|max:2048', 'events' => 'required|string', 'site_id' => 'nullable|integer|exists:sites,id']);
Webhook::create(['name' => $d['name'], 'url' => $d['url'], 'events' => array_values(array_filter(array_map('trim', explode(',', $d['events'])))), 'site_id' => $d['site_id'] ?? null, 'secret' => Str::random(48), 'is_active' => true]);
return back()->with('status', 'Webhook created.');
}
public function destroyWebhook(Webhook $webhook): RedirectResponse
{
$webhook->delete();
return back()->with('status','Webhook deleted.');
}
}
@@ -0,0 +1,54 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Modules\Api\Http\Resources\ContentResource;
use Modules\Blog\Models\Blog;
use Modules\News\Models\News;
use Modules\Pages\Models\Page;
final class ContentController
{
public function pages(): AnonymousResourceCollection
{
return $this->many(Page::query()->where('status', 'published'));
}
public function blog(): AnonymousResourceCollection
{
return $this->many(Blog::query()->published());
}
public function news(): AnonymousResourceCollection
{
return $this->many(News::query()->published());
}
public function page(Page $page): ContentResource
{
abort_unless($page->status === 'published', 404);
return new ContentResource($page);
}
public function blogPost(Blog $blog): ContentResource
{
abort_unless(Blog::query()->published()->whereKey($blog)->exists(), 404);
return new ContentResource($blog);
}
public function newsArticle(News $news): ContentResource
{
abort_unless(News::query()->published()->whereKey($news)->exists(), 404);
return new ContentResource($news);
}
private function many(Builder $q): AnonymousResourceCollection
{
return ContentResource::collection($q->latest('updated_at')->paginate((int) config('api.pagination', 20)));
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\JsonResponse;
final class OpenApiController
{
public function __invoke(): JsonResponse
{
return response()->json(['openapi' => '3.1.0', 'info' => config('api.openapi'), 'servers' => [['url' => url('/'.config('api.prefix', 'api/v1'))]], 'components' => ['securitySchemes' => ['bearerAuth' => ['type' => 'http', 'scheme' => 'bearer']]], 'paths' => ['/pages' => ['get' => ['summary' => 'List published pages', 'responses' => ['200' => ['description' => 'OK']]]], '/blog' => ['get' => ['summary' => 'List published blog posts', 'responses' => ['200' => ['description' => 'OK']]]], '/news' => ['get' => ['summary' => 'List published news', 'responses' => ['200' => ['description' => 'OK']]]], '/user' => ['get' => ['summary' => 'Authenticated user', 'security' => [['bearerAuth' => []]], 'responses' => ['200' => ['description' => 'OK'], '401' => ['description' => 'Unauthenticated']]]]]]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Api\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Modules\Api\Models\ApiToken;
use Symfony\Component\HttpFoundation\Response;
final class AuthenticateApiToken
{
public function handle(Request $r, Closure $next, ?string $ability = null): Response
{
$plain = $r->bearerToken();
if (! $plain) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}$token = ApiToken::with('user')->where('token', hash('sha256', $plain))->first();
if (! $token || ($token->expires_at && $token->expires_at->isPast()) || ! $token->user) {
return response()->json(['message' => 'Invalid or expired API token.'], 401);
}if ($ability && ! $token->allows($ability)) {
return response()->json(['message' => 'Insufficient token ability.'], 403);
}$token->update(['last_used_at' => now()]);
auth()->setUser($token->user);
$r->attributes->set('apiToken', $token);
return $next($r);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Api\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class ContentResource extends JsonResource
{
public function toArray(Request $r): array
{
return ['id' => $this->id, 'type' => $this->resource->getTable(), 'title' => $this->title, 'slug' => $this->slug, 'excerpt' => $this->excerpt ?? null, 'content' => $this->content ?? null, 'status' => $this->status ?? null, 'featured_image' => $this->featured_image ?? null, 'published_at' => $this->published_at ?? null, 'updated_at' => $this->updated_at];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Modules\Api\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
final class ApiToken extends Model
{
protected $fillable = ['user_id', 'name', 'token', 'abilities', 'last_used_at', 'expires_at'];
protected $hidden = ['token'];
protected function casts(): array
{
return ['abilities' => 'array', 'last_used_at' => 'datetime', 'expires_at' => 'datetime'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function issue(User $u, string $name, array $abilities = ['*'], ?\DateTimeInterface $expires = null): array
{
$plain = config('api.token_prefix', 'cms_').Str::random(64);
$record = self::query()->create(['user_id' => $u->id, 'name' => $name, 'token' => hash('sha256', $plain), 'abilities' => $abilities, 'expires_at' => $expires]);
return [$record, $plain];
}
public function allows(string $ability): bool
{
$a = $this->abilities ?: [];
return in_array('*', $a, true) || in_array($ability, $a, true);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Api\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Webhook extends Model
{
protected $fillable = ['site_id', 'name', 'url', 'secret', 'events', 'is_active', 'failure_count', 'last_triggered_at'];
protected $hidden = ['secret'];
protected function casts(): array
{
return ['events' => 'array', 'is_active' => 'boolean', 'last_triggered_at' => 'datetime'];
}
public function deliveries(): HasMany
{
return $this->hasMany(WebhookDelivery::class);
}
public function subscribesTo(string $event): bool
{
return in_array('*', $this->events ?: [], true) || in_array($event, $this->events ?: [], true);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Api\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class WebhookDelivery extends Model
{
protected $fillable = ['webhook_id', 'uuid', 'event', 'payload', 'response_status', 'response_body', 'attempts', 'delivered_at', 'failed_at'];
protected function casts(): array
{
return ['payload' => 'array', 'delivered_at' => 'datetime', 'failed_at' => 'datetime'];
}
public function webhook(): BelongsTo
{
return $this->belongsTo(Webhook::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Api\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Api\Services\WebhookDispatcher;
final class ApiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Api', 'Config/config.php'), 'api');
$this->app->singleton(WebhookDispatcher::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Api', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Api', 'Resources/views'), 'api');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\Api\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('api')->prefix(config('api.prefix', 'api/v1'))->name('api.v1.')->group(module_path('Api', 'Routes/api.php'));
Route::middleware('web')->group(module_path('Api', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="__('API & Webhooks')"><div class="space-y-8"><div><h1 class="text-2xl font-semibold">{{ __('API & Webhooks') }}</h1><p class="text-sm text-zinc-500">{{ __('Manage headless access, bearer tokens and webhook subscriptions.') }}</p></div>@if(session('status'))<flux:callout variant="success">{{ session('status') }}</flux:callout>@endif @if(session('api_token'))<flux:callout variant="warning"><strong>{{ __('Copy this token now') }}</strong><div class="mt-2 break-all font-mono">{{ session('api_token') }}</div></flux:callout>@endif<div class="grid gap-6 xl:grid-cols-2"><flux:card><h2 class="text-lg font-semibold">{{ __('Create token') }}</h2><form method="POST" action="{{ route('administration.api.tokens.store') }}" class="mt-4 space-y-4">@csrf<flux:input name="name" :label="__('Name')" required/><flux:input name="abilities" :label="__('Abilities')" value="content.read"/><flux:input name="expires_at" type="datetime-local" :label="__('Expires at')"/><flux:button type="submit" variant="primary">{{ __('Create') }}</flux:button></form></flux:card><flux:card><h2 class="text-lg font-semibold">{{ __('Create webhook') }}</h2><form method="POST" action="{{ route('administration.api.webhooks.store') }}" class="mt-4 space-y-4">@csrf<flux:input name="name" :label="__('Name')" required/><flux:input name="url" type="url" :label="__('URL')" required/><flux:input name="events" :label="__('Events')" value="content.published"/><flux:input name="site_id" type="number" :label="__('Site ID')"/><flux:button type="submit" variant="primary">{{ __('Create') }}</flux:button></form></flux:card></div><flux:card><div class="flex justify-between"><h2 class="text-lg font-semibold">{{ __('Tokens') }}</h2><a class="underline" href="{{ route('api.v1.openapi') }}">OpenAPI JSON</a></div>@forelse($tokens as $token)<div class="flex justify-between border-b py-3"><div>{{ $token->name }} <span class="text-sm text-zinc-500">{{ implode(', ', $token->abilities ?? []) }}</span></div><form method="POST" action="{{ route('administration.api.tokens.destroy',$token) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger">{{ __('Revoke') }}</flux:button></form></div>@empty<p class="py-4 text-zinc-500">{{ __('No tokens.') }}</p>@endforelse{{ $tokens->links() }}</flux:card><flux:card><h2 class="text-lg font-semibold">{{ __('Webhooks') }}</h2>@forelse($webhooks as $webhook)<div class="flex justify-between border-b py-3"><div>{{ $webhook->name }} <span class="text-sm text-zinc-500">{{ $webhook->url }}</span></div><form method="POST" action="{{ route('administration.api.webhooks.destroy',$webhook) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger">{{ __('Delete') }}</flux:button></form></div>@empty<p class="py-4 text-zinc-500">{{ __('No webhooks.') }}</p>@endforelse{{ $webhooks->links() }}</flux:card></div></x-layouts.app>
+18
View File
@@ -0,0 +1,18 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Controllers\ContentController;
use Modules\Api\Http\Controllers\OpenApiController;
use Modules\Api\Http\Middleware\AuthenticateApiToken;
Route::get('/openapi.json', OpenApiController::class)->name('openapi');
Route::controller(ContentController::class)->group(function (): void {
Route::get('/pages', 'pages');
Route::get('/pages/{page}', 'page');
Route::get('/blog', 'blog');
Route::get('/blog/{blog}', 'blogPost');
Route::get('/news', 'news');
Route::get('/news/{news}', 'newsArticle');
});
Route::middleware(AuthenticateApiToken::class)->get('/user', fn (Request $r) => $r->user());
+12
View File
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Controllers\ApiController;
Route::middleware(['web', 'auth', 'permission:api.manage'])->prefix('administration/api')->name('administration.api.')->controller(ApiController::class)->group(function (): void {
Route::get('/', 'index')->name('index');
Route::post('/tokens', 'storeToken')->name('tokens.store');
Route::delete('/tokens/{token}', 'destroyToken')->name('tokens.destroy');
Route::post('/webhooks', 'storeWebhook')->name('webhooks.store');
Route::delete('/webhooks/{webhook}', 'destroyWebhook')->name('webhooks.destroy');
});
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Api\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Modules\Api\Models\Webhook;
use Modules\Api\Models\WebhookDelivery;
final class WebhookDispatcher
{
public function dispatch(string $event, array $payload, ?int $siteId = null): void
{
Webhook::where('is_active', true)->get()->filter(fn (Webhook $h) => ($h->site_id === null || $h->site_id === $siteId) && $h->subscribesTo($event))->each(function (Webhook $h) use ($event, $payload): void {
$uuid = (string) Str::uuid();
$json = json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{}';
$delivery = WebhookDelivery::create(['webhook_id' => $h->id, 'uuid' => $uuid, 'event' => $event, 'payload' => $payload, 'attempts' => 1]);
try {
$res = Http::timeout((int) config('api.webhook_timeout', 10))->withHeaders(['X-CMS-Event' => $event, 'X-CMS-Delivery' => $uuid, 'X-CMS-Signature' => 'sha256='.hash_hmac('sha256', $json, $h->getRawOriginal('secret'))])->withBody($json, 'application/json')->post($h->url);
$delivery->update(['response_status' => $res->status(), 'response_body' => Str::limit($res->body(), 10000), 'delivered_at' => $res->successful() ? now() : null, 'failed_at' => $res->successful() ? null : now()]);
$h->update(['last_triggered_at' => now(), 'failure_count' => $res->successful() ? 0 : $h->failure_count + 1]);
} catch (\Throwable $e) {
$delivery->update(['response_body' => $e->getMessage(), 'failed_at' => now()]);
$h->increment('failure_count');
}
});
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use App\Models\User;
use Modules\Api\Models\ApiToken;
use Modules\Api\Providers\ApiServiceProvider;
it('boots the api module', fn () => expect(app()->providerIsLoaded(ApiServiceProvider::class))->toBeTrue());
it('hashes issued api tokens', function (): void {
$u = User::factory()->create();
[$t,$plain] = ApiToken::issue($u, 'Test', ['content.read']);
expect($t->getRawOriginal('token'))->toBe(hash('sha256', $plain))->not->toBe($plain);
});
it('publishes openapi documentation', fn () => $this->getJson('/api/v1/openapi.json')->assertOk()->assertJsonPath('openapi', '3.1.0'));
+1
View File
@@ -0,0 +1 @@
{"name":"Api","alias":"api","version":"1.0.0","description":"REST APIs, bearer tokens, webhooks and OpenAPI.","keywords":["api","headless","webhooks"],"priority":90,"providers":["Modules\\Api\\Providers\\ApiServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Sites"]}