diff --git a/Modules/Api/Config/config.php b/Modules/Api/Config/config.php new file mode 100644 index 0000000..2df068f --- /dev/null +++ b/Modules/Api/Config/config.php @@ -0,0 +1,2 @@ +'api/v1','token_prefix'=>'cms_','pagination'=>20,'webhook_timeout'=>10,'openapi'=>['title'=>env('APP_NAME','Content Manager').' API','version'=>'1.0.0']]; diff --git a/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php b/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php new file mode 100644 index 0000000..d0ee037 --- /dev/null +++ b/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php @@ -0,0 +1,3 @@ +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');} }; diff --git a/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php b/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php new file mode 100644 index 0000000..ede41c8 --- /dev/null +++ b/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php @@ -0,0 +1,3 @@ +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');} }; diff --git a/Modules/Api/Http/Controllers/ApiController.php b/Modules/Api/Http/Controllers/ApiController.php new file mode 100644 index 0000000..c9376ad --- /dev/null +++ b/Modules/Api/Http/Controllers/ApiController.php @@ -0,0 +1,4 @@ +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.');}} diff --git a/Modules/Api/Http/Controllers/ContentController.php b/Modules/Api/Http/Controllers/ContentController.php new file mode 100644 index 0000000..c3b1248 --- /dev/null +++ b/Modules/Api/Http/Controllers/ContentController.php @@ -0,0 +1,4 @@ +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)));}} diff --git a/Modules/Api/Http/Controllers/OpenApiController.php b/Modules/Api/Http/Controllers/OpenApiController.php new file mode 100644 index 0000000..7ff703d --- /dev/null +++ b/Modules/Api/Http/Controllers/OpenApiController.php @@ -0,0 +1,4 @@ +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']]]]]]);}} diff --git a/Modules/Api/Http/Middleware/AuthenticateApiToken.php b/Modules/Api/Http/Middleware/AuthenticateApiToken.php new file mode 100644 index 0000000..7554090 --- /dev/null +++ b/Modules/Api/Http/Middleware/AuthenticateApiToken.php @@ -0,0 +1,4 @@ +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);}} diff --git a/Modules/Api/Http/Resources/ContentResource.php b/Modules/Api/Http/Resources/ContentResource.php new file mode 100644 index 0000000..9728e78 --- /dev/null +++ b/Modules/Api/Http/Resources/ContentResource.php @@ -0,0 +1,4 @@ +$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];}} diff --git a/Modules/Api/Models/ApiToken.php b/Modules/Api/Models/ApiToken.php new file mode 100644 index 0000000..e96ced2 --- /dev/null +++ b/Modules/Api/Models/ApiToken.php @@ -0,0 +1,4 @@ +'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);} } diff --git a/Modules/Api/Models/Webhook.php b/Modules/Api/Models/Webhook.php new file mode 100644 index 0000000..58640ca --- /dev/null +++ b/Modules/Api/Models/Webhook.php @@ -0,0 +1,4 @@ +'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);}} diff --git a/Modules/Api/Models/WebhookDelivery.php b/Modules/Api/Models/WebhookDelivery.php new file mode 100644 index 0000000..5ba8a6e --- /dev/null +++ b/Modules/Api/Models/WebhookDelivery.php @@ -0,0 +1,4 @@ +'array','delivered_at'=>'datetime','failed_at'=>'datetime'];}public function webhook(): BelongsTo{return $this->belongsTo(Webhook::class);}} diff --git a/Modules/Api/Providers/ApiServiceProvider.php b/Modules/Api/Providers/ApiServiceProvider.php new file mode 100644 index 0000000..864d378 --- /dev/null +++ b/Modules/Api/Providers/ApiServiceProvider.php @@ -0,0 +1,5 @@ +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'); } } diff --git a/Modules/Api/Providers/RouteServiceProvider.php b/Modules/Api/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..8cc3f31 --- /dev/null +++ b/Modules/Api/Providers/RouteServiceProvider.php @@ -0,0 +1,5 @@ +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')); } } diff --git a/Modules/Api/Resources/views/admin/index.blade.php b/Modules/Api/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..b5d5117 --- /dev/null +++ b/Modules/Api/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

{{ __('API & Webhooks') }}

{{ __('Manage headless access, bearer tokens and webhook subscriptions.') }}

@if(session('status')){{ session('status') }}@endif @if(session('api_token')){{ __('Copy this token now') }}
{{ session('api_token') }}
@endif

{{ __('Create token') }}

@csrf{{ __('Create') }}

{{ __('Create webhook') }}

@csrf{{ __('Create') }}

{{ __('Tokens') }}

OpenAPI JSON
@forelse($tokens as $token)
{{ $token->name }} {{ implode(', ', $token->abilities ?? []) }}
@csrf @method('DELETE'){{ __('Revoke') }}
@empty

{{ __('No tokens.') }}

@endforelse{{ $tokens->links() }}

{{ __('Webhooks') }}

@forelse($webhooks as $webhook)
{{ $webhook->name }} {{ $webhook->url }}
@csrf @method('DELETE'){{ __('Delete') }}
@empty

{{ __('No webhooks.') }}

@endforelse{{ $webhooks->links() }}
diff --git a/Modules/Api/Routes/api.php b/Modules/Api/Routes/api.php new file mode 100644 index 0000000..6dc5978 --- /dev/null +++ b/Modules/Api/Routes/api.php @@ -0,0 +1,3 @@ +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()); diff --git a/Modules/Api/Routes/web.php b/Modules/Api/Routes/web.php new file mode 100644 index 0000000..75532d3 --- /dev/null +++ b/Modules/Api/Routes/web.php @@ -0,0 +1,3 @@ +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');}); diff --git a/Modules/Api/Services/WebhookDispatcher.php b/Modules/Api/Services/WebhookDispatcher.php new file mode 100644 index 0000000..9e509c3 --- /dev/null +++ b/Modules/Api/Services/WebhookDispatcher.php @@ -0,0 +1,4 @@ +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');}});}} diff --git a/Modules/Api/Tests/Feature/ApiModuleBootTest.php b/Modules/Api/Tests/Feature/ApiModuleBootTest.php new file mode 100644 index 0000000..4ea6229 --- /dev/null +++ b/Modules/Api/Tests/Feature/ApiModuleBootTest.php @@ -0,0 +1,3 @@ +expect(app()->providerIsLoaded(Modules\Api\Providers\ApiServiceProvider::class))->toBeTrue());it('hashes issued api tokens',function():void{$u=App\Models\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')); diff --git a/Modules/Api/module.json b/Modules/Api/module.json new file mode 100644 index 0000000..e9574cd --- /dev/null +++ b/Modules/Api/module.json @@ -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"]} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index ed3ad40..87be214 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -59,6 +59,8 @@ final class RolePermissionSeeder extends Seeder 'sites.manage', 'sites.assign', 'developer.access', + 'api.view', + 'api.manage', ]; foreach ($permissions as $permission) { diff --git a/modules_statuses.json b/modules_statuses.json index 32b21e4..a0025aa 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -34,5 +34,6 @@ "Workflow": true, "Localization": true, "Sites": true, - "Developer": true + "Developer": true, + "Api": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index dcd1009..2de4af9 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -32,7 +32,7 @@ {{ __('Testimonials') }} {{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }} -{{ __('Developer Tools') }} +{{ __('API & Webhooks') }}{{ __('Developer Tools') }} {{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }} {{ __('Localisation') }}{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}