Add PHP Media API scaffold and Docker configs

Initial project scaffold for a PHP Media API including routing, controllers, models and services under api/ (Router, Media/Cast/Image/Settings controllers, models, database/bootstrap files and automatic docs service). Adds Docker support (Dockerfile, docker-compose.yml, DOCKER_README.md, php-custom.ini), .htaccess for pretty URLs, API documentation and example payloads (API_EXAMPLES.md, api/README.md, api_examples/*.json), image handling service and logging, plus a comprehensive .gitignore. This commit provides a runnable development environment and example requests to get the API up and tested quickly.
This commit is contained in:
Lars Behrends
2026-04-12 00:46:30 +02:00
commit 66f69bc90d
54 changed files with 6035 additions and 0 deletions

110
api/services/ApiLogger.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
require_once __DIR__ . '/../database.php';
class ApiLogger {
private static $instance = null;
private $pdo;
private $enabled;
private function __construct() {
$this->enabled = API_LOGGING_ENABLED;
$db = new Database();
$this->pdo = $db->getConnection();
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function logRequest($method, $path, $params = [], $body = null) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, method, path, params, body)
VALUES ('REQUEST', :method, :path, :params, :body)
");
$methodValue = is_array($method) ? (json_encode($method) ?: '[array]') : (string)$method;
$pathValue = is_array($path) ? (json_encode($path) ?: '[array]') : (string)$path;
$paramsValue = is_array($params) ? (json_encode($params) ?: '[array]') : (string)$params;
$bodyValue = null;
if ($body) {
$bodyValue = is_array($body) ? (json_encode($body) ?: '[array]') : (string)$body;
}
$stmt->execute([
':method' => $methodValue,
':path' => $pathValue,
':params' => $paramsValue,
':body' => $bodyValue
]);
} catch (Exception $e) {
error_log('Failed to log request: ' . $e->getMessage());
}
}
public function logResponse($method, $path, $statusCode, $response) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, method, path, status_code, response)
VALUES ('RESPONSE', :method, :path, :status_code, :response)
");
$stmt->execute([
':method' => is_array($method) ? (json_encode($method) ?: '[array]') : (string)$method,
':path' => is_array($path) ? (json_encode($path) ?: '[array]') : (string)$path,
':status_code' => $statusCode,
':response' => (json_encode($response) ?: '[encoding_failed]')
]);
} catch (Exception $e) {
error_log('Failed to log response: ' . $e->getMessage());
}
}
public function logError($method, $path, $error) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, method, path, error)
VALUES ('ERROR', :method, :path, :error)
");
$stmt->execute([
':method' => is_array($method) ? (json_encode($method) ?: '[array]') : (string)$method,
':path' => is_array($path) ? (json_encode($path) ?: '[array]') : (string)$path,
':error' => is_array($error) ? (json_encode($error) ?: '[array]') : (string)$error
]);
} catch (Exception $e) {
error_log('Failed to log error: ' . $e->getMessage());
}
}
public function logDebug($message) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, message)
VALUES ('DEBUG', :message)
");
$stmt->execute([
':message' => is_array($message) ? (json_encode($message) ?: '[array]') : (string)$message
]);
} catch (Exception $e) {
error_log('Failed to log debug: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,204 @@
<?php
class DocumentationService {
private $controllersPath;
private $modelsPath;
public function __construct($controllersPath = __DIR__ . '/../controllers/', $modelsPath = __DIR__ . '/../models/') {
$this->controllersPath = $controllersPath;
$this->modelsPath = $modelsPath;
}
public function generateDocumentation() {
$docs = [
'title' => 'Media API Documentation',
'version' => '1.0.0',
'baseUrl' => '/api',
'endpoints' => [],
'models' => []
];
// Controller scannen
$docs['endpoints'] = $this->scanControllers();
// Modelle scannen
$docs['models'] = $this->scanModels();
return $docs;
}
private function scanControllers() {
$endpoints = [];
$controllerFiles = glob($this->controllersPath . '*Controller.php');
foreach ($controllerFiles as $file) {
$className = basename($file, '.php');
require_once $file;
$reflection = new ReflectionClass($className);
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
if ($method->getName() === '__construct') {
continue;
}
$docComment = $method->getDocComment();
$endpointInfo = $this->parseMethodDoc($docComment, $method->getName(), $className);
if ($endpointInfo) {
$endpoints[] = $endpointInfo;
}
}
}
return $endpoints;
}
private function parseMethodDoc($docComment, $methodName, $className) {
if (!$docComment) {
return null;
}
$info = [
'controller' => $className,
'method' => $methodName,
'description' => '',
'parameters' => [],
'response' => [],
'example' => null
];
// Beschreibung extrahieren
if (preg_match('/\*\s*(.+?)\n/', $docComment, $matches)) {
$info['description'] = trim($matches[1]);
}
// @param extrahieren
if (preg_match_all('/@param\s+(\w+)\s+\$(\w+)\s+(.+)/', $docComment, $matches)) {
foreach ($matches[1] as $i => $type) {
$info['parameters'][] = [
'name' => $matches[2][$i],
'type' => $type,
'description' => trim($matches[3][$i])
];
}
}
// @return extrahieren
if (preg_match('/@return\s+(\w+)\s+(.+)/', $docComment, $matches)) {
$info['response'] = [
'type' => $matches[1],
'description' => trim($matches[2])
];
}
// @example extrahieren
if (preg_match('/@example\s+(.+)/', $docComment, $matches)) {
$info['example'] = trim($matches[1]);
}
// HTTP-Methoden und Pfade aus Methodennamen ableiten
$info['httpMethods'] = $this->inferHttpMethods($methodName);
$info['path'] = $this->inferPath($className, $methodName);
return $info;
}
private function inferHttpMethods($methodName) {
$methods = [];
if (strpos($methodName, 'get') === 0) {
$methods[] = 'GET';
}
if (strpos($methodName, 'create') !== false || strpos($methodName, 'add') !== false || strpos($methodName, 'post') !== false) {
$methods[] = 'POST';
}
if (strpos($methodName, 'update') !== false) {
$methods[] = 'PUT';
}
if (strpos($methodName, 'delete') !== false) {
$methods[] = 'DELETE';
}
if (empty($methods)) {
$methods[] = 'GET'; // Default
}
return $methods;
}
private function inferPath($className, $methodName) {
$resource = strtolower(str_replace('Controller', '', $className));
$path = "/{$resource}";
if (strpos($methodName, 'getOne') !== false || strpos($methodName, 'update') !== false || strpos($methodName, 'delete') !== false) {
$path .= '/:id';
}
if (strpos($methodName, 'getMedia') !== false) {
$path .= '/:id/media';
}
if (strpos($methodName, 'handleEpisodes') !== false) {
$path .= '/:id/episodes';
}
if (strpos($methodName, 'handleTracks') !== false) {
$path .= '/:id/tracks';
}
if (strpos($methodName, 'handleAdult') !== false) {
$path .= '/adult';
}
return $path;
}
private function scanModels() {
$models = [];
$modelFiles = glob($this->modelsPath . '*.php');
foreach ($modelFiles as $file) {
$className = basename($file, '.php');
if ($className === 'BaseModel' || $className === 'MediaType') {
continue;
}
require_once $file;
$reflection = new ReflectionClass($className);
$docComment = $reflection->getDocComment();
$modelInfo = [
'name' => $className,
'description' => '',
'fields' => []
];
if ($docComment) {
if (preg_match('/\*\s*(.+?)\n/', $docComment, $matches)) {
$modelInfo['description'] = trim($matches[1]);
}
}
// Properties aus Klasse ableiten
$properties = $reflection->getProperties();
foreach ($properties as $property) {
if ($property->isPublic() || $property->isProtected()) {
$modelInfo['fields'][] = [
'name' => $property->getName(),
'type' => 'mixed',
'description' => ''
];
}
}
$models[] = $modelInfo;
}
return $models;
}
}

View File

@@ -0,0 +1,183 @@
<?php
class ImageHandler {
private $uploadDir;
private $baseUrl;
public function __construct($uploadDir = null, $baseUrl = null) {
$this->uploadDir = $uploadDir ?? __DIR__ . '/../public/images/';
$this->baseUrl = $baseUrl ?? '/images/';
// Ensure upload directory exists
if (!file_exists($this->uploadDir)) {
mkdir($this->uploadDir, 0755, true);
}
}
/**
* Process base64 image data and save to file
*
* @param string $base64Data Base64 encoded image data
* @param string $prefix Prefix for filename (e.g., 'poster', 'banner')
* @return string|null Relative path to saved image, or null if invalid
*/
public function saveBase64Image($base64Data, $prefix = 'image') {
error_log("ImageHandler: Starting to process base64 image, length: " . strlen($base64Data));
if (empty($base64Data)) {
error_log("ImageHandler: Empty base64 data");
return null;
}
// Check if it's already a URL (not base64)
if (filter_var($base64Data, FILTER_VALIDATE_URL)) {
error_log("ImageHandler: Data is already a URL: " . $base64Data);
return $base64Data;
}
// Check if it's already a file path
if (strpos($base64Data, '/images/') === 0) {
error_log("ImageHandler: Data is already a file path: " . $base64Data);
return $base64Data;
}
// Parse base64 data
if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $base64Data, $matches)) {
$extension = $matches[1];
$base64String = $matches[2];
error_log("ImageHandler: Data URI format detected, extension: " . $extension);
} elseif (preg_match('/^\/9j\//', $base64Data)) {
// Raw base64 without data URI prefix (JPEG)
$extension = 'jpg';
$base64String = $base64Data;
error_log("ImageHandler: Raw JPEG base64 detected");
} else {
// Try to detect from the base64 string itself
$extension = $this->detectImageFormat($base64Data);
if (!$extension) {
error_log("ImageHandler: Could not detect image format, defaulting to jpg");
$extension = 'jpg';
$base64String = $base64Data;
} else {
$base64String = $base64Data;
error_log("ImageHandler: Detected format: " . $extension);
}
}
// Decode base64
$imageData = base64_decode($base64String);
if ($imageData === false) {
error_log("ImageHandler: Base64 decode failed");
return null;
}
error_log("ImageHandler: Decoded image data size: " . strlen($imageData) . " bytes");
// Skip GD validation - just check if data looks reasonable
if (strlen($imageData) < 100) {
error_log("ImageHandler: Image data too small, likely invalid");
return null;
}
// Generate unique filename
$filename = $this->generateUniqueFilename($prefix, $extension);
$filepath = $this->uploadDir . $filename;
error_log("ImageHandler: Attempting to save to: " . $filepath);
// Ensure directory exists and is writable (handle subdirectories)
$directory = dirname($filepath);
if (!file_exists($directory)) {
error_log("ImageHandler: Creating directory: " . $directory);
if (!mkdir($directory, 0755, true)) {
error_log("ImageHandler: Failed to create directory");
return null;
}
}
if (!is_writable($directory)) {
error_log("ImageHandler: Directory not writable: " . $directory);
error_log("ImageHandler: Attempting to chmod directory");
chmod($directory, 0755);
}
// Save file
$bytesWritten = file_put_contents($filepath, $imageData);
if ($bytesWritten === false) {
error_log("ImageHandler: Failed to save file to: " . $filepath);
error_log("ImageHandler: Upload directory exists: " . (file_exists($this->uploadDir) ? 'yes' : 'no'));
error_log("ImageHandler: Upload directory writable: " . (is_writable($this->uploadDir) ? 'yes' : 'no'));
return null;
}
error_log("ImageHandler: Successfully saved " . $bytesWritten . " bytes to: " . $filepath);
// Return relative path
return $this->baseUrl . $filename;
}
/**
* Detect image format from base64 string
*/
private function detectImageFormat($base64String) {
// Decode first few bytes to check magic numbers
$data = base64_decode(substr($base64String, 0, 100));
if (substr($data, 0, 3) === "\xFF\xD8\xFF") {
return 'jpg';
} elseif (substr($data, 0, 8) === "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") {
return 'png';
} elseif (substr($data, 0, 6) === "GIF87a" || substr($data, 0, 6) === "GIF89a") {
return 'gif';
} elseif (substr($data, 0, 4) === "RIFF" && substr($data, 8, 4) === "WEBP") {
return 'webp';
}
// Default to jpg for game posters
return 'jpg';
}
/**
* Validate that data is a valid image
*/
private function isValidImage($data) {
try {
$image = imagecreatefromstring($data);
if ($image !== false) {
imagedestroy($image);
return true;
}
} catch (Exception $e) {
return false;
}
return false;
}
/**
* Generate unique filename
*/
private function generateUniqueFilename($prefix, $extension) {
return $prefix . '_' . uniqid() . '_' . time() . '.' . $extension;
}
/**
* Delete an image file
*/
public function deleteImage($imagePath) {
if (empty($imagePath)) {
return false;
}
// Convert URL to filesystem path
if (strpos($imagePath, $this->baseUrl) === 0) {
$filename = substr($imagePath, strlen($this->baseUrl));
$filepath = $this->uploadDir . $filename;
if (file_exists($filepath)) {
return unlink($filepath);
}
}
return false;
}
}