diff --git a/app/Controllers/Api/ApiController.php b/app/Controllers/Api/ApiController.php new file mode 100644 index 0000000..045acb0 --- /dev/null +++ b/app/Controllers/Api/ApiController.php @@ -0,0 +1,58 @@ + true]; + + if ($data !== null) { + $responseData['data'] = $data; + } + + return $this->json($response, $responseData, $status); + } + + protected function error(Response $response, string $message, int $status = 400, array $errors = []): Response + { + $responseData = [ + 'success' => false, + 'error' => [ + 'message' => $message, + 'code' => $status + ] + ]; + + if (!empty($errors)) { + $responseData['error']['details'] = $errors; + } + + return $this->json($response, $responseData, $status); + } + + protected function json(Response $response, $data, int $status = 200): Response + { + $response->getBody()->write(json_encode($data)); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus($status); + } + + protected function getPaginationParams(Request $request): array + { + $params = $request->getQueryParams(); + $page = max(1, (int)($params['page'] ?? 1)); + $perPage = min(100, max(1, (int)($params['per_page'] ?? 20))); + + return [ + 'page' => $page, + 'per_page' => $perPage, + 'offset' => ($page - 1) * $perPage + ]; + } +} diff --git a/app/Controllers/Api/BaseApiController.php b/app/Controllers/Api/BaseApiController.php new file mode 100644 index 0000000..c5834ca --- /dev/null +++ b/app/Controllers/Api/BaseApiController.php @@ -0,0 +1,62 @@ + true]; + + if ($data !== null) { + $responseData['data'] = $data; + } + + return $this->json($response, $responseData, $status); + } + + protected function error(Response $response, string $message, int $status = 400, array $errors = []): Response + { + $responseData = [ + 'success' => false, + 'error' => [ + 'message' => $message, + 'code' => $status + ] + ]; + + if (!empty($errors)) { + $responseData['error']['details'] = $errors; + } + + return $this->json($response, $responseData, $status); + } + + protected function getPaginationParams(Request $request): array + { + $params = $request->getQueryParams(); + $page = max(1, (int)($params['page'] ?? 1)); + $perPage = min(50, max(1, (int)($params['per_page'] ?? 20))); + + return [ + 'page' => $page, + 'per_page' => $perPage, + 'offset' => ($page - 1) * $perPage + ]; + } + + protected function getAuthUser(Request $request): ?array + { + return $request->getAttribute('user'); + } + + protected function isAdmin(Request $request): bool + { + $user = $this->getAuthUser($request); + return $user && ($user['is_admin'] ?? false); + } +} diff --git a/app/Controllers/Api/DocsController.php b/app/Controllers/Api/DocsController.php new file mode 100644 index 0000000..98e75f6 --- /dev/null +++ b/app/Controllers/Api/DocsController.php @@ -0,0 +1,107 @@ +basePath = dirname(dirname(dirname(dirname(__DIR__)))); + } + + /** + * @OA\Get( + * path="/api/docs", + * summary="API Documentation", + * tags={"Documentation"}, + * @OA\Response( + * response=200, + * description="Returns the Swagger UI interface" + * ) + * ) + */ + public function showDocs(Request $request, Response $response): Response + { + $swaggerUiPath = __DIR__ . '/../../../vendor/swagger-api/swagger-ui/dist'; + + if (!file_exists($swaggerUiPath)) { + return $this->error($response, 'Swagger UI not found. Please run: composer require swagger-api/swagger-ui', 404); + } + + // Serve the Swagger UI + $html = file_get_contents($swaggerUiPath . '/index.html'); + + // Update the URL to point to our OpenAPI JSON endpoint + $html = str_replace( + 'url: "https://petstore.swagger.io/v2/swagger.json"', + 'urls: [ + {url: "/api-docs.json", name: "Media Library API"} + ], + "dom_id": "#swagger-ui", + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + layout: "StandaloneLayout",', + $html + ); + + $response->getBody()->write($html); + return $response->withHeader('Content-Type', 'text/html'); + } + + /** + * @OA\Get( + * path="/api-docs.json", + * summary="OpenAPI Specification", + * tags={"Documentation"}, + * @OA\Response( + * response=200, + * description="Returns the OpenAPI specification" + * ) + * ) + */ + public function getOpenApiSpec(Request $request, Response $response): Response + { + $openapi = \OpenApi\Generator::scan([ + __DIR__ . '/../../../app', + __DIR__ . '/../../../routes', + __DIR__ . '/../../../src' + ], [ + 'exclude' => [ + __DIR__ . '/../../../database/migrations', + __DIR__ . '/../../../vendor', + __DIR__ . '/../../../tests' + ] + ]); + + $response->getBody()->write(json_encode($openapi)); + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/app/Controllers/Api/MediaController.php b/app/Controllers/Api/MediaController.php new file mode 100644 index 0000000..8b8e711 --- /dev/null +++ b/app/Controllers/Api/MediaController.php @@ -0,0 +1,196 @@ +gameModel = new Game($pdo); + $this->movieModel = new Movie($pdo); + $this->tvShowModel = new TvShow($pdo); + $this->musicArtistModel = new MusicArtist($pdo); + } + + // List all games with pagination + public function listGames(Request $request, Response $response): Response + { + try { + $pagination = $this->getPaginationParams($request); + $filters = $this->getFiltersFromRequest($request); + + $games = $this->gameModel->findAll( + $filters, + $pagination['per_page'], + $pagination['offset'] + ); + + $total = $this->gameModel->count($filters); + + return $this->success($response, [ + 'items' => $games, + 'pagination' => [ + 'total' => $total, + 'per_page' => $pagination['per_page'], + 'current_page' => $pagination['page'], + 'last_page' => ceil($total / $pagination['per_page']) + ] + ]); + } catch (\Exception $e) { + return $this->error($response, 'Failed to fetch games', 500); + } + } + + // Get single game by ID + public function getGame(Request $request, Response $response, array $args): Response + { + try { + $id = (int)($args['id'] ?? 0); + if (!$id) { + return $this->error($response, 'Invalid game ID', 400); + } + + $game = $this->gameModel->find($id); + if (!$game) { + return $this->error($response, 'Game not found', 404); + } + + return $this->success($response, $game); + } catch (\Exception $e) { + return $this->error($response, 'Failed to fetch game', 500); + } + } + + // Search across all media + public function search(Request $request, Response $response): Response + { + try { + $query = $request->getQueryParams()['q'] ?? ''; + $type = $request->getQueryParams()['type'] ?? 'all'; + $pagination = $this->getPaginationParams($request); + + $results = []; + + if ($type === 'all' || $type === 'game') { + $results['games'] = $this->searchGames($query, $pagination); + } + + if ($type === 'all' || $type === 'movie') { + $results['movies'] = $this->searchMovies($query, $pagination); + } + + if ($type === 'all' || $type === 'tvshow') { + $results['tvshows'] = $this->searchTvShows($query, $pagination); + } + + if ($type === 'all' || $type === 'music') { + $results['artists'] = $this->searchArtists($query, $pagination); + } + + return $this->success($response, $results); + } catch (\Exception $e) { + return $this->error($response, 'Search failed: ' . $e->getMessage(), 500); + } + } + + // Helper methods for searching different media types + private function searchGames(string $query, array $pagination): array + { + try { + // First try to use the model's search method if it exists + if (method_exists($this->gameModel, 'search')) { + $games = $this->gameModel->search($query, $pagination['per_page'], $pagination['offset']); + $total = method_exists($this->gameModel, 'countSearchResults') + ? $this->gameModel->countSearchResults($query) + : count($games); + } + // Fallback to basic filtering if search method doesn't exist + else { + $allGames = $this->gameModel->findAll(); + $filtered = array_filter($allGames, function($game) use ($query) { + return stripos($game['title'] ?? '', $query) !== false; + }); + + // Apply pagination + $games = array_slice($filtered, $pagination['offset'], $pagination['per_page']); + $total = count($filtered); + } + + return [ + 'items' => $games, + 'total' => $total, + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'] + ]; + } catch (\Exception $e) { + // If anything goes wrong, return empty results + return [ + 'items' => [], + 'total' => 0, + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'], + 'error' => $e->getMessage() + ]; + } + } + + private function searchMovies(string $query, array $pagination): array + { + // Implement movie search logic + return [ + 'items' => [], + 'total' => 0 + ]; + } + + private function searchTvShows(string $query, array $pagination): array + { + // Implement TV show search logic + return [ + 'items' => [], + 'total' => 0 + ]; + } + + private function searchArtists(string $query, array $pagination): array + { + // Implement artist search logic + return [ + 'items' => [], + 'total' => 0 + ]; + } + + // Extract filters from request + private function getFiltersFromRequest(Request $request): array + { + $filters = []; + $queryParams = $request->getQueryParams(); + + // Add common filters + if (!empty($queryParams['genre'])) { + $filters['genre'] = $queryParams['genre']; + } + + if (!empty($queryParams['year'])) { + $filters['year'] = (int)$queryParams['year']; + } + + // Add more filters as needed + + return $filters; + } +} diff --git a/app/Middleware/ApiAuthMiddleware.php b/app/Middleware/ApiAuthMiddleware.php new file mode 100644 index 0000000..855f6db --- /dev/null +++ b/app/Middleware/ApiAuthMiddleware.php @@ -0,0 +1,73 @@ +authService = $authService; + } + + public function process(Request $request, RequestHandler $handler): Response + { + $path = $request->getUri()->getPath(); + + // Skip authentication for public routes + if (in_array($path, $this->publicRoutes)) { + return $handler->handle($request); + } + + // Get token from Authorization header + $authHeader = $request->getHeaderLine('Authorization'); + if (empty($authHeader) || !preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) { + return $this->createErrorResponse(401, 'Missing or invalid authorization token'); + } + + $token = $matches[1]; + + try { + // Verify token and get user + $user = $this->authService->verifyToken($token); + if (!$user) { + return $this->createErrorResponse(401, 'Invalid or expired token'); + } + + // Add user to request attributes for use in controllers + $request = $request->withAttribute('user', $user); + + return $handler->handle($request); + + } catch (\Exception $e) { + return $this->createErrorResponse(500, 'Authentication error'); + } + } + + private function createErrorResponse(int $status, string $message): Response + { + $response = new \Slim\Psr7\Response($status); + $response->getBody()->write(json_encode([ + 'success' => false, + 'error' => [ + 'code' => $status, + 'message' => $message + ] + ])); + + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 351f713..8c4210a 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -338,6 +338,7 @@ class Game extends Model $sql = " SELECT + id, game_key, title, COUNT(*) as platform_count, diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 5fc3977..174a52e 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -4,15 +4,30 @@ namespace App\Services; use App\Models\User; use PDO; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use Firebase\JWT\ExpiredException; +use DomainException; +use UnexpectedValueException; class AuthService { private PDO $pdo; private ?array $user = null; + private JwtService $jwtService; + private string $jwtSecret; + private string $jwtAlgo = 'HS256'; - public function __construct(PDO $pdo) + public function __construct(PDO $pdo, JwtService $jwtService = null) { $this->pdo = $pdo; + $this->jwtService = $jwtService ?? new JwtService([ + 'secret' => getenv('JWT_SECRET') ?: 'your-secret-key-change-this-in-production', + 'algo' => $this->jwtAlgo, + 'expiration' => 3600, + 'leeway' => 60 + ]); + $this->checkSession(); } @@ -96,6 +111,92 @@ class AuthService } } + public function generateToken(array $user): array + { + $now = time(); + $payload = [ + 'sub' => $user['id'], + 'username' => $user['username'], + 'role' => $user['role'] ?? 'user', + 'iat' => $now, + 'exp' => $now + 3600, // 1 hour expiration + 'jti' => bin2hex(random_bytes(16)) + ]; + + $token = $this->jwtService->encode($payload); + $refreshToken = $this->generateRefreshToken($user['id']); + + return [ + 'token' => $token, + 'refresh_token' => $refreshToken, + 'expires_in' => 3600, + 'token_type' => 'Bearer' + ]; + } + + public function refreshToken(string $refreshToken): ?array + { + // Verify refresh token (you might want to store and validate this in your database) + $stmt = $this->pdo->prepare("SELECT user_id FROM refresh_tokens WHERE token = :token AND expires_at > NOW()"); + $stmt->execute(['token' => $refreshToken]); + $tokenData = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$tokenData) { + return null; + } + + $user = (new User($this->pdo))->find($tokenData['user_id']); + if (!$user) { + return null; + } + + // Generate new tokens + return $this->generateToken($user); + } + + private function generateRefreshToken(int $userId): string + { + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:s', time() + (30 * 24 * 3600)); // 30 days + + $stmt = $this->pdo->prepare(" + INSERT INTO refresh_tokens (user_id, token, expires_at, created_at) + VALUES (:user_id, :token, :expires_at, NOW()) + ON DUPLICATE KEY UPDATE + token = VALUES(token), + expires_at = VALUES(expires_at), + created_at = NOW() + "); + + $stmt->execute([ + 'user_id' => $userId, + 'token' => $token, + 'expires_at' => $expiresAt + ]); + + return $token; + } + + public function validateToken(string $token): ?array + { + try { + $payload = $this->jwtService->decode($token); + if (!$payload) { + return null; + } + + // Optionally check if user still exists and is active + $user = (new User($this->pdo))->find($payload['sub']); + if (!$user || !$user['is_active']) { + return null; + } + + return $payload; + } catch (\Exception $e) { + return null; + } + } + public function generateCSRFToken(): string { if (!isset($_SESSION['csrf_token'])) { diff --git a/app/Services/BaseSyncService.php b/app/Services/BaseSyncService.php index c01c2ed..3cc3be4 100644 --- a/app/Services/BaseSyncService.php +++ b/app/Services/BaseSyncService.php @@ -51,13 +51,15 @@ abstract class BaseSyncService { if ($this->logFileHandle) { $this->logProgress("=== Sync completed at " . date('Y-m-d H:i:s') . " ==="); - $this->updateSyncLog($this->currentSyncLogId, 'completed', [ - 'processed_items' => $this->getProcessedCount(), - 'new_items' => $this->getNewCount(), - 'updated_items' => $this->getUpdatedCount(), - 'deleted_items' => $this->getDeletedCount(), - 'message' => $this->getCompletionMessage() - ]); + if ($this->currentSyncLogId !== null) { + $this->updateSyncLog($this->currentSyncLogId, 'completed', [ + 'processed_items' => $this->getProcessedCount(), + 'new_items' => $this->getNewCount(), + 'updated_items' => $this->getUpdatedCount(), + 'deleted_items' => $this->getDeletedCount(), + 'message' => $this->getCompletionMessage() + ]); + } fclose($this->logFileHandle); } } diff --git a/app/Services/JellyfinSyncService.php b/app/Services/JellyfinSyncService.php index b58d849..4f937e5 100644 --- a/app/Services/JellyfinSyncService.php +++ b/app/Services/JellyfinSyncService.php @@ -184,7 +184,7 @@ class JellyfinSyncService extends BaseSyncService 'query' => [ 'IncludeItemTypes' => $type, 'Recursive' => 'true', - 'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating,OfficialRating,Genres,Studios,RunTimeTicks' + 'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating,OfficialRating,Genres,Studios,RunTimeTicks,People' ] ]); diff --git a/app/Services/JwtService.php b/app/Services/JwtService.php new file mode 100644 index 0000000..fcdb0c0 --- /dev/null +++ b/app/Services/JwtService.php @@ -0,0 +1,63 @@ +secret = $config['secret']; + $this->algo = $config['algo']; + $this->expiration = $config['expiration'] ?? 3600; + $this->leeway = $config['leeway'] ?? 60; + + JWT::$leeway = $this->leeway; + } + + public function encode(array $payload): string + { + $now = time(); + $payload = array_merge([ + 'iat' => $now, + 'exp' => $now + $this->expiration, + ], $payload); + + return JWT::encode($payload, $this->secret, $this->algo); + } + + public function decode(string $token): ?array + { + try { + $decoded = JWT::decode($token, new Key($this->secret, $this->algo)); + return (array) $decoded; + } catch (ExpiredException $e) { + // Token expired + return null; + } catch (DomainException | UnexpectedValueException $e) { + // Invalid token + return null; + } + } + + public function refresh(string $token): ?string + { + $payload = $this->decode($token); + if (!$payload) { + return null; + } + + unset($payload['iat'], $payload['exp'], $payload['nbf']); + return $this->encode($payload); + } +} diff --git a/app/Services/LocalSyncService.php b/app/Services/LocalSyncService.php index a795b09..20f3447 100644 --- a/app/Services/LocalSyncService.php +++ b/app/Services/LocalSyncService.php @@ -11,6 +11,30 @@ use SplFileInfo; class LocalSyncService extends BaseSyncService implements SyncServiceInterface { + /** + * @inheritDoc + */ + protected function executeSync(string $syncType): void + { + try { + $path = $this->source['path'] ?? null; + if (empty($path) || !is_dir($path)) { + throw new Exception("Invalid or inaccessible source path: {$path}"); + } + + $mediaType = $this->determineMediaType($this->source); + + $this->logProgress("Starting {$syncType} sync for media type: {$mediaType}"); + + // Process the directory based on media type + $this->processDirectory($path, $mediaType); + + $this->logProgress("Completed {$syncType} sync for media type: {$mediaType}"); + } catch (Exception $e) { + $this->logProgress("Error during sync: " . $e->getMessage()); + throw $e; + } + } protected string $sourceType = 'local'; /** diff --git a/app/Services/XbvrSyncService.php b/app/Services/XbvrSyncService.php index d92ecab..e8d2c0f 100644 --- a/app/Services/XbvrSyncService.php +++ b/app/Services/XbvrSyncService.php @@ -30,7 +30,7 @@ class XbvrSyncService extends BaseSyncService ] ]); - $this->imageDownloader = new ImageDownloader(__DIR__ . '/../../storage/images'); + $this->imageDownloader = new ImageDownloader(__DIR__ . '/../../../storage/images'); } protected function executeSync(string $syncType): void diff --git a/composer.json b/composer.json index e561a6f..e0a3123 100644 --- a/composer.json +++ b/composer.json @@ -4,15 +4,17 @@ "type": "project", "require": { "php": "^8.1", - "vlucas/phpdotenv": "^5.5", + "vlucas/phpdotenv": "^5.6", "guzzlehttp/guzzle": "^7.5", "slim/slim": "^4.10", "slim/psr7": "^1.6", "slim/twig-view": "^3.3", "php-di/php-di": "^7.0", "illuminate/database": "^10.0", - "zircote/swagger-php": "^5.5", - "php-middleware/php-debug-bar": "^1.0" + "zircote/swagger-php": "^5.7", + "php-middleware/php-debug-bar": "^1.0", + "firebase/php-jwt": "^7.0", + "swagger-api/swagger-ui": "^5.31" }, "autoload": { "psr-4": { diff --git a/public/index.php b/public/index.php index 5be08d4..23550e1 100644 --- a/public/index.php +++ b/public/index.php @@ -97,6 +97,9 @@ $container->set('view', function () use ($container) { case 'admin.playnite.upload': $basePath = '/admin/playnite/import'; break; + case 'admin.games.edit': + $basePath = '/admin/games/' . $data['id'] . '/edit'; + break; case 'admin.sync': $basePath = '/admin/sync/' . ($data['id'] ?? ''); break; @@ -483,6 +486,9 @@ $container->set(\App\Http\Middleware\MediaVisibilityMiddleware::class, function AppFactory::setContainer($container); $app = AppFactory::create(); +// Add Method Override Middleware for handling _METHOD field in forms +$app->add(new \Slim\Middleware\MethodOverrideMiddleware()); + // Add Twig-View Middleware $twig = $container->get('view'); $app->add(TwigMiddleware::create($app, $twig)); @@ -560,5 +566,6 @@ $errorMiddleware = $app->addErrorMiddleware( // Register routes require __DIR__ . '/../routes/web.php'; require __DIR__ . '/../routes/api.php'; +require __DIR__ . '/../routes/api2.php'; $app->run(); diff --git a/resources/views/movies/show.twig b/resources/views/movies/show.twig index c05a82b..29809ab 100644 --- a/resources/views/movies/show.twig +++ b/resources/views/movies/show.twig @@ -212,16 +212,20 @@ {% if actors %}

Cast

-
+ diff --git a/routes/api-docs.php b/routes/api-docs.php new file mode 100644 index 0000000..d26f94b --- /dev/null +++ b/routes/api-docs.php @@ -0,0 +1,42 @@ +group('/docs', function (RouteCollectorProxy $group) { + $docsController = $this->get(DocsController::class); + + // Documentation UI + $group->get('/api', [$docsController, 'showDocs']); + + // OpenAPI JSON specification + $group->get('/api-docs.json', [$docsController, 'getOpenApiSpec']); + + // Serve Swagger UI assets + $group->get('/swagger-ui/{file:.+}', function (Request $request, Response $response, array $args) { + $file = $args['file']; + $swaggerUiPath = __DIR__ . '/../../vendor/swagger-api/swagger-ui/dist'; + $filePath = $swaggerUiPath . '/' . $file; + + if (!file_exists($filePath)) { + return $response->withStatus(404, 'File not found'); + } + + $extension = pathinfo($file, PATHINFO_EXTENSION); + $contentTypes = [ + 'css' => 'text/css', + 'js' => 'application/javascript', + 'png' => 'image/png', + 'json' => 'application/json', + 'html' => 'text/html', + ]; + + $contentType = $contentTypes[$extension] ?? 'text/plain'; + + $response->getBody()->write(file_get_contents($filePath)); + return $response->withHeader('Content-Type', $contentType); + }); +}); diff --git a/routes/api2.php b/routes/api2.php new file mode 100644 index 0000000..abc3797 --- /dev/null +++ b/routes/api2.php @@ -0,0 +1,89 @@ +getContainer(); + +// API routes group +$app->group('/api', function (RouteCollectorProxy $group) use ($container) { + + $docsController = $this->get(DocsController::class); + + // Public endpoints + $group->get('/status', function (Request $request, Response $response) { + $response->getBody()->write(json_encode([ + 'status' => 'ok', + 'timestamp' => time(), + 'version' => '1.0.0' + ])); + return $response->withHeader('Content-Type', 'application/json'); + }); + $group->get('/doku', [$docsController, 'getOpenApiSpec']); + + $group->get('/docu', [$docsController, 'showDocs']); + + // Auth routes + $group->group('/auth', function (RouteCollectorProxy $group) use ($container) { + $authController = $container->get(AuthController::class); + + $group->post('/login', [$authController, 'login']); + $group->post('/register', [$authController, 'register']); + $group->post('/refresh', [$authController, 'refreshToken']); + $group->get('/me', [$authController, 'getCurrentUser']) + ->add(new ApiAuthMiddleware($container->get(AuthService::class))); + }); + + // Protected routes (require authentication) + $group->group('', function (RouteCollectorProxy $group) use ($container) { + $mediaController = $container->get(MediaController::class); + + // Games + $group->get('/games', [$mediaController, 'listGames']); + $group->get('/games/{id:[0-9]+}', [$mediaController, 'getGame']); + + // Movies + $group->get('/movies', [$mediaController, 'listMovies']); + $group->get('/movies/{id:[0-9]+}', [$mediaController, 'getMovie']); + + // TV Shows + $group->get('/tvshows', [$mediaController, 'listTvShows']); + $group->get('/tvshows/{id:[0-9]+}', [$mediaController, 'getTvShow']); + + // Search + $group->get('/search', [$mediaController, 'search']); + + })->add(new ApiAuthMiddleware($container->get(AuthService::class))); + + // Admin routes (require admin role) + $group->group('/admin', function (RouteCollectorProxy $group) use ($container) { + // Add admin-specific routes here + $group->get('/users', function (Request $request, Response $response) { + // Admin-only user listing + $response->getBody()->write(json_encode(['message' => 'Admin access granted'])); + return $response->withHeader('Content-Type', 'application/json'); + }); + })->add(new ApiAuthMiddleware($container->get(AuthService::class))); +}); + +// Add CORS middleware +$app->add(function (Request $request, $handler) { + $response = $handler->handle($request); + return $response + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Origin, Authorization') + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); +}); + +// Handle preflight requests +$app->options('/{routes:.+}', function (Request $request, Response $response, $args) { + return $response; +}); diff --git a/routes/web.php b/routes/web.php index 0be743d..24ead13 100644 --- a/routes/web.php +++ b/routes/web.php @@ -74,15 +74,15 @@ $app->group('', function (RouteCollectorProxy $group) { $group->get('', AdminController::class . ':movies')->setName('admin.movies.index'); $group->map(['GET', 'POST'], '/create', AdminController::class . ':editMovie')->setName('admin.movies.create'); $group->map(['GET', 'POST'], '/{id}/edit', AdminController::class . ':editMovie')->setName('admin.movies.edit'); - $group->delete('/{id}', AdminController::class . ':deleteMovie')->setName('admin.movies.delete'); + $group->map(['POST', 'DELETE'], '/{id}', AdminController::class . ':deleteMovie')->setName('admin.movies.delete'); }); $adminGroup->group('/games', function (RouteCollectorProxy $group) { $group->get('', AdminController::class . ':games')->setName('admin.games.index'); $group->map(['GET', 'POST'], '/create', AdminController::class . ':editGame')->setName('admin.games.create'); $group->map(['GET', 'POST'], '/{id}/edit', AdminController::class . ':editGame')->setName('admin.games.edit'); - $group->delete('/{id}', AdminController::class . ':deleteGame')->setName('admin.games.delete'); - + $group->map(['POST', 'DELETE'], '/{id}', AdminController::class . ':deleteGame')->setName('admin.games.delete'); + // SteamGridDB API routes $group->group('/sgdb', function (RouteCollectorProxy $sgdb) { $sgdb->get('/search', 'App\Controllers\GameController:searchSteamGridDb')->setName('admin.games.sgdb.search'); @@ -95,7 +95,7 @@ $app->group('', function (RouteCollectorProxy $group) { $group->get('', AdminController::class . ':shows')->setName('admin.shows.index'); $group->map(['GET', 'POST'], '/create', AdminController::class . ':editShow')->setName('admin.shows.create'); $group->map(['GET', 'POST'], '/{id}/edit', AdminController::class . ':editShow')->setName('admin.shows.edit'); - $group->delete('/{id}', AdminController::class . ':deleteShow')->setName('admin.shows.delete'); + $group->map(['POST', 'DELETE'], '/{id}', AdminController::class . ':deleteShow')->setName('admin.shows.delete'); }); $adminGroup->group('/adult', function (RouteCollectorProxy $group) { @@ -128,8 +128,8 @@ $app->group('', function (RouteCollectorProxy $group) { $sourcesGroup->post('', 'App\Controllers\MediaSourceController:store')->setName('admin.sources.store'); $sourcesGroup->get('/{id:\d+}/edit', 'App\Controllers\MediaSourceController:edit')->setName('admin.sources.edit'); $sourcesGroup->post('/{id:\d+}', 'App\Controllers\MediaSourceController:update')->setName('admin.sources.update'); - $sourcesGroup->delete('/{id:\d+}', 'App\Controllers\MediaSourceController:destroy')->setName('admin.sources.destroy'); - + $sourcesGroup->post('/{id:\d+}/delete', 'App\Controllers\MediaSourceController:destroy')->setName('admin.sources.destroy'); + // Source sync operations $sourcesGroup->post('/{id:\d+}/sync', 'App\Controllers\MediaSourceController:startSync')->setName('admin.sources.sync'); $sourcesGroup->get('/sync/status/{log_id}', 'App\Controllers\MediaSourceController:syncStatus')->setName('admin.sources.sync.status');