Apply strict_types and extensive type declarations throughout the API and models, improving type safety and error handling. Key changes: add declare(strict_types=1) to many files; convert properties, method parameters and return values to typed signatures (PDO, arrays, ints, strings, bools, nullables); switch exception handling to Throwable in index and Router; improve Router, controllers and model method signatures and nullability handling; refine file/image serving security checks and headers in ImageController; strengthen Database typing and initialization methods; return explicit types from BaseModel CRUD helpers and counting; update Media/Cast/Adult/Game/Console/Settings controllers and models to use typed methods, better validation, and clearer update/create return types. Also add AGENTS.md (agent skills index), update README with Swagger/OpenAPI usage instructions, and add /.windsurf to .gitignore. These changes aim to harden runtime correctness, make intended contracts explicit, and prepare the codebase for easier maintenance and static analysis.
186 lines
6.6 KiB
PHP
186 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
class ImageHandler {
|
|
private string $uploadDir;
|
|
private string $baseUrl;
|
|
|
|
public function __construct(?string $uploadDir = null, ?string $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(string $base64Data, string $prefix = 'image'): ?string {
|
|
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(string $base64String): ?string {
|
|
// 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(string $data): bool {
|
|
try {
|
|
$image = imagecreatefromstring($data);
|
|
if ($image !== false) {
|
|
imagedestroy($image);
|
|
return true;
|
|
}
|
|
} catch (Exception $e) {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Generate unique filename
|
|
*/
|
|
private function generateUniqueFilename(string $prefix, string $extension): string {
|
|
return $prefix . '_' . uniqid() . '_' . time() . '.' . $extension;
|
|
}
|
|
|
|
/**
|
|
* Delete an image file
|
|
*/
|
|
public function deleteImage(?string $imagePath): bool {
|
|
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;
|
|
}
|
|
}
|