diff --git a/app/Http/Controllers/Api/V1/GameApiController.php b/app/Http/Controllers/Api/V1/GameApiController.php new file mode 100644 index 0000000..b1ae0c6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/GameApiController.php @@ -0,0 +1,146 @@ + $game->id, + 'title' => $game->title, + 'slug' => $game->slug, + 'cover_url' => $game->cover_url, + 'release_year' => $game->release_year, + 'release_date' => $game->release_date?->toDateString(), + 'rating_avg' => $game->rating_avg ? (float) $game->rating_avg : null, + 'rating_count' => $game->rating_count, + 'library_count' => $game->library_count, + 'genres' => $game->genres->pluck('name'), + 'platforms' => $game->platforms->pluck('name'), + ]; + } + + public function index(Request $request): JsonResponse + { + $query = Game::with(['genres', 'platforms'])->published(); + + if ($request->filled('genre')) { + $query->whereHas('genres', fn ($q) => $q->where('genres.id', $request->genre)); + } + + if ($request->filled('platform')) { + $query->whereHas('platforms', fn ($q) => $q->where('platforms.id', $request->platform)); + } + + if ($request->filled('year')) { + $query->where('release_year', $request->year); + } + + match ($request->get('sort', 'popular')) { + 'rating' => $query->orderByDesc('rating_avg'), + 'release' => $query->orderByDesc('release_date'), + 'title' => $query->orderBy('title'), + default => $query->orderByDesc('library_count'), + }; + + $paginator = $query->paginate(20); + + return response()->json([ + 'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]); + } + + public function show(Game $game): JsonResponse + { + $game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']); + + return response()->json([ + 'data' => array_merge($this->formatGame($game), [ + 'description' => $game->summary, + 'companies' => $game->companies->map(fn ($c) => [ + 'name' => $c->name, + 'role' => $c->pivot->role, + ]), + 'screenshots' => $game->screenshots->pluck('url'), + 'reviews' => $game->reviews->take(5)->map(fn ($r) => [ + 'user' => $r->user->slug, + 'body' => $r->body, + 'rating' => $r->rating, + 'created_at' => $r->created_at->toIso8601String(), + ]), + ]), + ]); + } + + public function search(Request $request): JsonResponse + { + $q = trim((string) $request->get('q', '')); + + if (mb_strlen($q) < 2) { + return response()->json(['error' => 'Query must be at least 2 characters.'], 422); + } + + $games = Game::with(['genres', 'platforms']) + ->where('title', 'like', '%' . $q . '%') + ->published() + ->orderBy('title') + ->limit(20) + ->get(); + + return response()->json([ + 'data' => $games->map(fn ($game) => $this->formatGame($game))->values(), + 'meta' => ['total' => $games->count()], + ]); + } + + public function topRated(Request $request): JsonResponse + { + $paginator = Game::with(['genres', 'platforms']) + ->published() + ->where('rating_count', '>=', 1) + ->orderByDesc('rating_avg') + ->orderByDesc('rating_count') + ->paginate(20); + + return response()->json([ + 'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]); + } + + public function newReleases(Request $request): JsonResponse + { + $paginator = Game::with(['genres', 'platforms']) + ->published() + ->where('release_date', '<=', now()) + ->orderByDesc('release_date') + ->paginate(20); + + return response()->json([ + 'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/MetaApiController.php b/app/Http/Controllers/Api/V1/MetaApiController.php new file mode 100644 index 0000000..9fc62af --- /dev/null +++ b/app/Http/Controllers/Api/V1/MetaApiController.php @@ -0,0 +1,25 @@ +json([ + 'data' => Genre::orderBy('name')->get(['id', 'name', 'slug']), + ]); + } + + public function platforms(): JsonResponse + { + return response()->json([ + 'data' => Platform::orderBy('name')->get(['id', 'name', 'slug']), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/UserApiController.php b/app/Http/Controllers/Api/V1/UserApiController.php new file mode 100644 index 0000000..f36cffc --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserApiController.php @@ -0,0 +1,58 @@ +library(); + + return response()->json([ + 'data' => [ + 'username' => $user->slug, + 'name' => $user->name, + 'avatar_url' => $user->avatar, + 'joined_at' => $user->created_at->toIso8601String(), + 'stats' => [ + 'played' => (clone $library)->where('status', 'played')->count(), + 'playing' => (clone $library)->where('status', 'playing')->count(), + 'want_to_play' => (clone $library)->where('status', 'want_to_play')->count(), + 'total' => $user->library()->count(), + ], + ], + ]); + } + + public function library(User $user, Request $request): JsonResponse + { + $paginator = $user->library() + ->with('game') + ->orderByDesc('updated_at') + ->paginate(20); + + return response()->json([ + 'data' => $paginator->map(fn ($ug) => [ + 'game' => [ + 'title' => $ug->game->title, + 'slug' => $ug->game->slug, + 'cover_url' => $ug->game->cover_url, + ], + 'status' => $ug->status, + 'rating' => $ug->rating, + 'updated_at' => $ug->updated_at->toIso8601String(), + ])->values(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]); + } +} diff --git a/app/Http/Controllers/ApiKeyController.php b/app/Http/Controllers/ApiKeyController.php new file mode 100644 index 0000000..65b0d1b --- /dev/null +++ b/app/Http/Controllers/ApiKeyController.php @@ -0,0 +1,32 @@ +user()->apiKeys()->latest()->get(); + return view('settings.api-keys', compact('keys')); + } + + public function store(Request $request) + { + $request->validate(['name' => 'required|string|max:50']); + + $apiKey = ApiKey::generate(auth()->user(), $request->name); + + return redirect()->route('settings.api-keys')->with('new_api_key', $apiKey->key); + } + + public function destroy(ApiKey $apiKey) + { + Gate::authorize('delete', $apiKey); + $apiKey->delete(); + return redirect()->route('settings.api-keys')->with('success', 'API key deleted.'); + } +} diff --git a/app/Http/Middleware/AuthenticateApiKey.php b/app/Http/Middleware/AuthenticateApiKey.php new file mode 100644 index 0000000..2f09685 --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiKey.php @@ -0,0 +1,31 @@ +bearerToken() ?? $request->get('api_key'); + + if (! $key) { + return response()->json(['error' => 'Invalid API key'], 401); + } + + $apiKey = ApiKey::where('key', $key)->with('user')->first(); + + if (! $apiKey) { + return response()->json(['error' => 'Invalid API key'], 401); + } + + auth()->login($apiKey->user); + $apiKey->update(['last_used_at' => now()]); + + return $next($request); + } +} diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php new file mode 100644 index 0000000..c8f4144 --- /dev/null +++ b/app/Models/ApiKey.php @@ -0,0 +1,25 @@ +belongsTo(User::class); + } + + public static function generate(User $user, string $name): self + { + return self::create([ + 'user_id' => $user->id, + 'name' => $name, + 'key' => bin2hex(random_bytes(32)), + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 069cad4..c099f08 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -19,6 +19,7 @@ class User extends Authenticatable public function library(): HasMany { return $this->hasMany(UserGame::class); } public function reviews(): HasMany { return $this->hasMany(Review::class); } public function lists(): HasMany { return $this->hasMany(UserList::class); } + public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); } public function activities(): HasMany { return $this->hasMany(Activity::class)->latest(); } public function notifications(): HasMany { return $this->hasMany(Notification::class)->latest('created_at'); } diff --git a/app/Policies/ApiKeyPolicy.php b/app/Policies/ApiKeyPolicy.php new file mode 100644 index 0000000..d6ec071 --- /dev/null +++ b/app/Policies/ApiKeyPolicy.php @@ -0,0 +1,14 @@ +id === $apiKey->user_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6cc9d94..7f7e731 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,7 +4,10 @@ namespace App\Providers; use App\Models\UserGame; use App\Policies\UserGamePolicy; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -26,5 +29,13 @@ class AppServiceProvider extends ServiceProvider Gate::policy(\App\Models\Review::class, \App\Policies\ReviewPolicy::class); Gate::policy(\App\Models\UserList::class, \App\Policies\ListPolicy::class); Gate::policy(\App\Models\GameComment::class, \App\Policies\GameCommentPolicy::class); + Gate::policy(\App\Models\ApiKey::class, \App\Policies\ApiKeyPolicy::class); + + RateLimiter::for('api', function (Request $request) { + $key = $request->bearerToken() ?? $request->get('api_key') ?? $request->ip(); + return Limit::perMinute(60)->by($key)->response(function () { + return response()->json(['error' => 'Too many requests. Limit: 60/min.'], 429); + }); + }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 7a2848f..8c35ec0 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'api.key' => \App\Http\Middleware\AuthenticateApiKey::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/migrations/2026_05_31_000001_create_api_keys_table.php b/database/migrations/2026_05_31_000001_create_api_keys_table.php new file mode 100644 index 0000000..5d0594d --- /dev/null +++ b/database/migrations/2026_05_31_000001_create_api_keys_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name', 50); + $table->string('key', 64)->unique(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; diff --git a/docs/superpowers/plans/2026-05-31-public-api-v1.md b/docs/superpowers/plans/2026-05-31-public-api-v1.md new file mode 100644 index 0000000..21d1b4d --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-public-api-v1.md @@ -0,0 +1,998 @@ +# Public API v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a versioned public REST API (v1) with API key authentication, rate limiting, and a documentation page. + +**Architecture:** API key auth via `Authorization: Bearer` header or `?api_key=` query param; all endpoints under `/api/v1/` prefix with 60 req/min throttle. API key management lives in the web app under `/settings/api-keys`. JSON controllers live in `App\Http\Controllers\Api\V1\`. + +**Tech Stack:** Laravel 11, PHP 8.3, Blade + Tailwind + Alpine.js, no additional packages required. + +--- + +## File Map + +**Create:** +- `database/migrations/2026_05_31_000001_create_api_keys_table.php` +- `app/Models/ApiKey.php` +- `app/Http/Middleware/AuthenticateApiKey.php` +- `app/Http/Controllers/ApiKeyController.php` +- `app/Http/Controllers/Api/V1/GameApiController.php` +- `app/Http/Controllers/Api/V1/MetaApiController.php` +- `app/Http/Controllers/Api/V1/UserApiController.php` +- `resources/views/settings/api-keys.blade.php` +- `resources/views/api/docs.blade.php` +- `routes/api.php` + +**Modify:** +- `bootstrap/app.php` — register `api.key` middleware alias, load `routes/api.php`, add JSON 404 +- `app/Providers/AppServiceProvider.php` — add rate limiter +- `routes/web.php` — add API key management routes +- `resources/views/layouts/app.blade.php` — add "API" footer/nav link + +--- + +### Task 1: Database migration for `api_keys` + +**Files:** +- Create: `database/migrations/2026_05_31_000001_create_api_keys_table.php` + +- [ ] **Step 1: Create migration file** + +```php +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name', 50); + $table->string('key', 64)->unique(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; +``` + +- [ ] **Step 2: Run migration** + +```bash +php artisan migrate +``` +Expected: `Migrating: 2026_05_31_000001_create_api_keys_table` then `Migrated`. + +--- + +### Task 2: `ApiKey` model + +**Files:** +- Create: `app/Models/ApiKey.php` + +- [ ] **Step 1: Create model** + +```php +belongsTo(User::class); + } + + public static function generate(User $user, string $name): self + { + return self::create([ + 'user_id' => $user->id, + 'name' => $name, + 'key' => bin2hex(random_bytes(32)), // 64 hex chars + ]); + } +} +``` + +--- + +### Task 3: `AuthenticateApiKey` middleware + +**Files:** +- Create: `app/Http/Middleware/AuthenticateApiKey.php` + +- [ ] **Step 1: Create middleware** + +```php +bearerToken() ?? $request->get('api_key'); + + if (! $key) { + return response()->json(['error' => 'Invalid API key'], 401); + } + + $apiKey = ApiKey::where('key', $key)->with('user')->first(); + + if (! $apiKey) { + return response()->json(['error' => 'Invalid API key'], 401); + } + + auth()->login($apiKey->user); + $apiKey->update(['last_used_at' => now()]); + + return $next($request); + } +} +``` + +--- + +### Task 4: Register middleware alias and load API routes in `bootstrap/app.php` + +**Files:** +- Modify: `bootstrap/app.php` + +- [ ] **Step 1: Update `bootstrap/app.php`** + +Replace the entire file with: + +```php +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'api.key' => \App\Http\Middleware\AuthenticateApiKey::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*'), + ); + })->create(); +``` + +Note: Laravel 11's `withRouting(api:)` automatically prefixes `/api` and applies the `api` middleware group. Since we want `/api/v1/` paths, we'll use explicit Route::prefix in `routes/api.php` WITHOUT the automatic `/api` prefix — so we define routes in `web.php` or use a custom file. Actually, Laravel 11's `api:` param in `withRouting` wraps routes in `Route::middleware('api')->prefix('api')`. That would make our paths `/api/api/v1/...`. Instead, add the API routes file directly in `web.php` via `require`. See Task 8 for the approach. + +**Revised Step 1: Update `bootstrap/app.php` (without `api:` param)** + +```php +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'api.key' => \App\Http\Middleware\AuthenticateApiKey::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*'), + ); + })->create(); +``` + +--- + +### Task 5: Rate limiter in `AppServiceProvider` + +**Files:** +- Modify: `app/Providers/AppServiceProvider.php` + +- [ ] **Step 1: Add rate limiter to `boot()`** + +```php +bearerToken() ?? $request->get('api_key') ?? $request->ip(); + return Limit::perMinute(60)->by($key)->response(function () { + return response()->json(['error' => 'Too many requests. Limit: 60/min.'], 429); + }); + }); + } +} +``` + +--- + +### Task 6: API routes file + +**Files:** +- Create: `routes/api.php` +- Modify: `routes/web.php` (add `require` at bottom) + +- [ ] **Step 1: Create `routes/api.php`** + +```php +middleware(['api.key', 'throttle:api'])->group(function () { + // Games + Route::get('/games', [GameApiController::class, 'index']); + Route::get('/games/search', [GameApiController::class, 'search']); + Route::get('/games/top-rated', [GameApiController::class, 'topRated']); + Route::get('/games/new-releases', [GameApiController::class, 'newReleases']); + Route::get('/games/{game:slug}', [GameApiController::class, 'show']); + + // Meta + Route::get('/genres', [MetaApiController::class, 'genres']); + Route::get('/platforms', [MetaApiController::class, 'platforms']); + + // Users (public profiles) + Route::get('/users/{user:slug}', [UserApiController::class, 'show']); + Route::get('/users/{user:slug}/library', [UserApiController::class, 'library']); +}); +``` + +Note: The spec uses `{user:username}` but the User model's route key is `slug` (renamed from username in migration `2026_05_30_000002`). Using `{user:slug}` is correct. + +- [ ] **Step 2: Include `api.php` from `web.php`** + +Add at the very bottom of `routes/web.php`: + +```php +require __DIR__.'/api.php'; +``` + +--- + +### Task 7: `ApiKeyController` + +**Files:** +- Create: `app/Http/Controllers/ApiKeyController.php` + +- [ ] **Step 1: Create controller** + +```php +user()->apiKeys()->latest()->get(); + return view('settings.api-keys', compact('keys')); + } + + public function store(Request $request) + { + $request->validate(['name' => 'required|string|max:50']); + + $apiKey = ApiKey::generate(auth()->user(), $request->name); + + return redirect()->route('settings.api-keys')->with('new_api_key', $apiKey->key); + } + + public function destroy(ApiKey $apiKey) + { + Gate::authorize('delete', $apiKey); + $apiKey->delete(); + return redirect()->route('settings.api-keys')->with('success', 'API key deleted.'); + } +} +``` + +- [ ] **Step 2: Add `apiKeys` relation to `User` model** + +In `app/Models/User.php`, add: + +```php +public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); } +``` + +- [ ] **Step 3: Add policy for `ApiKey`** + +Create `app/Policies/ApiKeyPolicy.php`: + +```php +id === $apiKey->user_id; + } +} +``` + +- [ ] **Step 4: Register policy in `AppServiceProvider`** + +Add to `boot()` in `AppServiceProvider.php`: + +```php +Gate::policy(\App\Models\ApiKey::class, \App\Policies\ApiKeyPolicy::class); +``` + +--- + +### Task 8: Web routes for API key management + +**Files:** +- Modify: `routes/web.php` + +- [ ] **Step 1: Add API key management routes inside `auth` middleware group** + +Add before the `require __DIR__.'/auth.php'` line: + +```php +// API Key Management +Route::middleware('auth')->group(function () { + Route::get('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'index'])->name('settings.api-keys'); + Route::post('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'store'])->name('settings.api-keys.store'); + Route::delete('/settings/api-keys/{apiKey}', [\App\Http\Controllers\ApiKeyController::class, 'destroy'])->name('settings.api-keys.destroy'); +}); +``` + +--- + +### Task 9: `settings/api-keys.blade.php` view + +**Files:** +- Create: `resources/views/settings/api-keys.blade.php` + +- [ ] **Step 1: Create view** + +```blade +@extends('layouts.app') + +@section('title', 'API Keys — LoreKeepers') + +@section('content') +
Use API keys to access the LoreKeepers API. Keep them secret — treat them like passwords.
+ + {{-- New key revealed --}} + @if(session('new_api_key')) +{{ session('new_api_key') }}
+
+ Programmatic access to the LoreKeepers game database and user libraries.
+All API requests require an API key. Generate one in Settings → API Keys.
+Pass the key via the Authorization header:
Authorization: Bearer YOUR_API_KEY+
Alternatively, append ?api_key=YOUR_API_KEY as a query parameter.
https://gamelog.ro/api/v1
+ Returns HTTP 429 when exceeded.
+/api/v1{{ $ep[1] }}
+ All list endpoints return paginated responses wrapped in data + meta:
{
+ "data": [ ... ],
+ "meta": {
+ "current_page": 1,
+ "last_page": 5,
+ "per_page": 20,
+ "total": 93
+ }
+}
+ curl https://gamelog.ro/api/v1/games/top-rated \ + -H "Authorization: Bearer YOUR_API_KEY"+
| Status | +Meaning | +
|---|---|
| 401 | Missing or invalid API key |
| 404 | Resource not found |
| 422 | Validation error (e.g. q too short) |
| 429 | Rate limit exceeded (60 req/min) |
Programmatic access to the LoreKeepers game database and user libraries.
+All API requests require an API key. @auth Generate one in Settings → API Keys. @else Sign up and generate one in Settings → API Keys. @endauth
+Pass the key via the Authorization header:
Authorization: Bearer YOUR_API_KEY+
Alternatively, append ?api_key=YOUR_API_KEY as a query parameter.
https://gamelog.ro/api/v1
+ Returns HTTP 429 when exceeded.
+/api/v1{{ $ep[1] }}
+ All list endpoints return paginated responses wrapped in data + meta:
{
+ "data": [ ... ],
+ "meta": {
+ "current_page": 1,
+ "last_page": 5,
+ "per_page": 20,
+ "total": 93
+ }
+}
+ curl https://gamelog.ro/api/v1/games/top-rated \ + -H "Authorization: Bearer YOUR_API_KEY"+
| Status | +Meaning | +
|---|---|
| 401 | Missing or invalid API key |
| 404 | Resource not found |
| 422 | Validation error (e.g. q too short) |
| 429 | Rate limit exceeded (60 req/min) |