Enhance API functionality and improve JWT authentication

- Added JWT authentication support in AuthService and JwtService.
- Implemented token generation and refresh mechanisms.
- Updated ApiAuthMiddleware to handle authentication for protected routes.
- Created ApiController and BaseApiController for standardized API responses.
- Developed MediaController for managing media items with pagination and search capabilities.
- Introduced DocsController for serving API documentation via Swagger UI.
- Added routes for API documentation and media management.
- Improved error handling and response formatting across API endpoints.
- Updated composer.json to include necessary JWT and Swagger UI dependencies.
This commit is contained in:
Lars Behrends
2025-12-31 10:08:49 +01:00
parent 1b053148f0
commit b728b0c72d
18 changed files with 858 additions and 27 deletions

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Controllers\Api;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
abstract class ApiController
{
protected function success(Response $response, $data = null, int $status = 200): Response
{
$responseData = ['success' => 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
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Controllers\Api;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Controllers\Controller;
class BaseApiController extends Controller
{
protected function success(Response $response, $data = null, int $status = 200): Response
{
$responseData = ['success' => 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);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Controllers\Api;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use OpenApi\Annotations as OA;
/**
* @OA\Info(
* title="Media Library API",
* version="1.0.0",
* description="API documentation for the Media Library application"
* )
* @OA\Server(
* url="/api",
* description="API Server"
* )
* @OA\SecurityScheme(
* securityScheme="bearerAuth",
* type="http",
* scheme="bearer",
* bearerFormat="JWT"
* )
*/
use App\Controllers\Api\ApiController;
class DocsController extends ApiController
{
private $basePath;
public function __construct()
{
$this->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');
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Controllers\Api;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\Game;
use App\Models\Movie;
use App\Models\TvShow;
use App\Models\MusicArtist;
use App\Controllers\Api\ApiController;
class MediaController extends ApiController
{
private $gameModel;
private $movieModel;
private $tvShowModel;
private $musicArtistModel;
public function __construct(\PDO $pdo)
{
$this->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;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use App\Services\AuthService;
class ApiAuthMiddleware implements MiddlewareInterface
{
private AuthService $authService;
private array $publicRoutes = [
'/api/auth/check',
'/api/auth/login',
'/api/auth/register',
'/api/status'
];
public function __construct(AuthService $authService)
{
$this->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');
}
}

View File

@@ -338,6 +338,7 @@ class Game extends Model
$sql = "
SELECT
id,
game_key,
title,
COUNT(*) as platform_count,

View File

@@ -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'])) {

View File

@@ -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);
}
}

View File

@@ -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'
]
]);

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use DomainException;
use UnexpectedValueException;
class JwtService
{
private $secret;
private $algo;
private $expiration;
private $leeway;
public function __construct(array $config)
{
$this->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);
}
}

View File

@@ -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';
/**

View File

@@ -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