style updates
This commit is contained in:
@@ -1,2 +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']];
|
||||
|
||||
return ['prefix' => 'api/v1', 'token_prefix' => 'cms_', 'pagination' => 20, 'webhook_timeout' => 10, 'openapi' => ['title' => env('APP_NAME', 'Content Manager').' API', 'version' => '1.0.0']];
|
||||
|
||||
@@ -1,3 +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');} };
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +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');} };
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +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.');}}
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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)));}}
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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']]]]]]);}}
|
||||
|
||||
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']]]]]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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);}}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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];}}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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);} }
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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);}}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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);}}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +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'); } }
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +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')); } }
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +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());
|
||||
|
||||
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());
|
||||
|
||||
@@ -1,3 +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');});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1,4 +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');}});}}
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Api\Models\ApiToken;
|
||||
it('boots the api module',fn()=>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'));
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user