<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// Include required classes
require_once 'classes/DataManager.php';
require_once 'classes/ImageProcessor.php';
require_once 'classes/FileUploadHandler.php';
require_once 'classes/RSSGenerator.php';
require_once 'classes/ErrorHandler.php';
require_once 'classes/InputValidator.php';
require_once 'classes/SimpleAuth.php';
class PhotoGalleryAPI
{
/**
* @var mixed
*/
private $albumsPath;
/**
* @var DataManager
*/
private $dataManager;
/**
* @var ErrorHandler
*/
private $errorHandler;
/**
* @var InputValidator
*/
private $inputValidator;
/**
* @var SimpleAuth
*/
private $auth;
public function __construct()
{
$this->albumsPath = __DIR__.'/albums/';
$this->dataManager = new DataManager();
$this->auth = new SimpleAuth();
// Initialize error handling and validation
$this->errorHandler = new ErrorHandler('logs/api_error.log', 'INFO', true);
$this->inputValidator = new InputValidator($this->errorHandler);
// Initialize data if needed
try {
$this->dataManager->initializeData();
} catch (Exception $e) {
// Use new error handler for logging
$this->errorHandler->logError(
'DataManager initialization failed: '.$e->getMessage(),
'CONFIGURATION_ERROR',
['exception' => get_class($e)],
['method' => 'constructor']
);
}
}
/**
* Handle incoming API requests with comprehensive error handling
* @return string JSON response
*/
public function handleRequest()
{
try {
// Log incoming request for audit trail
$this->errorHandler->logInfo('API Request', [
'action' => $_GET['action'] ?? 'none',
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
]);
$action = $_GET['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
// Validate action parameter
if (empty($action)) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Action parameter is required',
'MISSING_REQUIRED_FIELD',
['parameter' => 'action']
);
return $this->sendResponse($errorResponse);
}
// Define allowed actions and their required methods
$allowedActions = [
'getAlbums' => ['GET'],
'getPhotos' => ['GET'],
'createAlbum' => ['POST'],
'updateAlbum' => ['POST'],
'deleteAlbum' => ['POST'],
'getTags' => ['GET'],
'createTag' => ['POST'],
'deleteTag' => ['POST'],
'updateAlbumTags' => ['POST'],
'uploadPhoto' => ['POST'],
'deletePhoto' => ['POST'],
'updateRSSSettings' => ['POST'],
'getConfig' => ['GET'],
'updateConfig' => ['POST'],
'getStorageStats' => ['GET'],
'cleanupOrphanedFiles' => ['POST'],
'generateThumbnails' => ['POST'],
'getAlbumStorageUsage' => ['GET'],
'checkAuth' => ['GET']
];
// Validate action exists
if (!isset($allowedActions[$action])) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid action: '.$action,
'INVALID_PARAMETER',
['action' => $action, 'allowed_actions' => array_keys($allowedActions)]
);
return $this->sendResponse($errorResponse);
}
// Validate HTTP method
$allowedMethods = $allowedActions[$action];
if (!in_array($method, $allowedMethods)) {
$errorResponse = $this->errorHandler->createErrorResponse(
"Method {$method} not allowed for action {$action}. Allowed methods: ".implode(', ', $allowedMethods),
'METHOD_NOT_ALLOWED',
['method' => $method, 'action' => $action, 'allowed_methods' => $allowedMethods]
);
return $this->sendResponse($errorResponse);
}
// Route to appropriate method
switch ($action) {
case 'getAlbums':
return $this->getAlbums();
case 'getPhotos':
return $this->getPhotos();
case 'createAlbum':
$this->auth->requireAuth();
return $this->createAlbum();
case 'updateAlbum':
$this->auth->requireAuth();
return $this->updateAlbum();
case 'deleteAlbum':
$this->auth->requireAuth();
return $this->deleteAlbum();
case 'getTags':
return $this->getTags();
case 'createTag':
$this->auth->requireAuth();
return $this->createTag();
case 'deleteTag':
$this->auth->requireAuth();
return $this->deleteTag();
case 'updateAlbumTags':
$this->auth->requireAuth();
return $this->updateAlbumTags();
case 'uploadPhoto':
$this->auth->requireAuth();
return $this->uploadPhoto();
case 'deletePhoto':
$this->auth->requireAuth();
return $this->deletePhoto();
case 'updateRSSSettings':
$this->auth->requireAuth();
return $this->updateRSSSettings();
case 'getConfig':
return $this->getConfig();
case 'updateConfig':
$this->auth->requireAuth();
return $this->updateConfig();
case 'getStorageStats':
$this->auth->requireAuth();
return $this->getStorageStats();
case 'cleanupOrphanedFiles':
$this->auth->requireAuth();
return $this->cleanupOrphanedFiles();
case 'generateThumbnails':
$this->auth->requireAuth();
return $this->generateThumbnails();
case 'getAlbumStorageUsage':
$this->auth->requireAuth();
return $this->getAlbumStorageUsage();
case 'checkAuth':
return $this->checkAuthStatus();
default:
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid action: "'.$action.'" (length: '.strlen($action).')',
'INVALID_ACTION'
);
return $this->sendResponse($errorResponse);
}
} catch (Exception $e) {
// Handle any unexpected exceptions
$errorResponse = $this->errorHandler->handleException($e, 'handleRequest');
return $this->sendResponse($errorResponse);
}
}
/**
* Create new album with enhanced validation and error handling
* @return string JSON response
*/
private function createAlbum()
{
try {
// Get and validate JSON input
$inputRaw = file_get_contents('php://input');
if ($inputRaw === false) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to read request body',
'INVALID_INPUT'
);
return $this->sendResponse($errorResponse);
}
// Validate JSON format
$input = json_decode($inputRaw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid JSON input: '.json_last_error_msg(),
'INVALID_JSON'
);
return $this->sendResponse($errorResponse);
}
// Validate album creation input
$validationError = $this->inputValidator->validateAlbumCreation($input);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Sanitize input
$title = trim($input['title']);
$description = isset($input['description']) ? trim($input['description']) : '';
// Create album in database
$albumId = $this->dataManager->createAlbum($title, $description);
// Create physical album directory
$albumPath = $this->albumsPath.$albumId;
if (!is_dir($albumPath)) {
if (!mkdir($albumPath, 0755, true)) {
// Rollback database entry
try {
$this->dataManager->deleteAlbum($albumId);
} catch (Exception $rollbackError) {
$this->errorHandler->logError(
'Failed to rollback album creation: '.$rollbackError->getMessage(),
'DATABASE_ERROR',
['album_id' => $albumId],
['method' => 'createAlbum', 'operation' => 'rollback']
);
}
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to create album directory',
'FILE_SYSTEM_ERROR',
['album_path' => $albumPath]
);
return $this->sendResponse($errorResponse);
}
}
// Get the created album data
$album = $this->dataManager->getAlbum($albumId);
if (!$album) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album created but failed to retrieve data',
'DATABASE_ERROR',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
$successResponse = $this->errorHandler->createSuccessResponse([
'message' => 'Album created successfully',
'album' => $album
], 201, 'Album created successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'createAlbum');
return $this->sendResponse($errorResponse);
}
}
/**
* Update existing album with enhanced validation and error handling
* @return string JSON response
*/
private function updateAlbum()
{
try {
// Validate album ID parameter
$albumId = $_GET['albumId'] ?? '';
$validationError = $this->inputValidator->validateAlbumId($albumId);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Get and validate JSON input
$inputRaw = file_get_contents('php://input');
if ($inputRaw === false) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to read request body',
'INVALID_INPUT'
);
return $this->sendResponse($errorResponse);
}
// Validate JSON format
$input = json_decode($inputRaw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid JSON input: '.json_last_error_msg(),
'INVALID_JSON'
);
return $this->sendResponse($errorResponse);
}
// Validate album update input
$validationError = $this->inputValidator->validateAlbumUpdate($input);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Validate album exists
$existingAlbum = $this->dataManager->getAlbum($albumId);
if (!$existingAlbum) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album not found',
'ALBUM_NOT_FOUND',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
// Prepare sanitized update data
$updateData = [];
if (isset($input['title'])) {
$updateData['title'] = trim($input['title']);
}
if (isset($input['description'])) {
$updateData['description'] = trim($input['description']);
}
if (isset($input['is_rss_enabled'])) {
$updateData['is_rss_enabled'] = (bool) $input['is_rss_enabled'];
}
// Update album
$success = $this->dataManager->updateAlbum($albumId, $updateData);
if (!$success) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to update album',
'DATABASE_ERROR',
['album_id' => $albumId, 'update_data' => $updateData]
);
return $this->sendResponse($errorResponse);
}
// Get updated album data
$album = $this->dataManager->getAlbum($albumId);
// Trigger RSS feed auto-update if RSS settings were changed or album is RSS-enabled
if (isset($updateData['is_rss_enabled']) || !empty($album['is_rss_enabled'])) {
$this->triggerRSSAutoUpdate();
}
$successResponse = $this->errorHandler->createSuccessResponse([
'message' => 'Album updated successfully',
'album' => $album
], 200, 'Album updated successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'updateAlbum');
return $this->sendResponse($errorResponse);
}
}
/**
* Delete a tag with enhanced validation and error handling
* @return string JSON response
*/
private function deleteTag()
{
try {
// รับ tagId จากคำขอ
$tagId = $_GET['tagId'] ?? '';
// ตรวจสอบความถูกต้องของ tagId
$validationError = $this->inputValidator->validateTagId($tagId);
if ($validationError) {
return $this->sendResponse($validationError);
}
// ตรวจสอบว่า tag มีอยู่จริง
$existingTag = $this->dataManager->getTag($tagId);
if (!$existingTag) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Tag not found',
'TAG_NOT_FOUND',
['tag_id' => $tagId]
);
return $this->sendResponse($errorResponse);
}
// ลบ tag ออกจากฐานข้อมูล
$success = $this->dataManager->deleteTag($tagId);
if (!$success) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to delete tag',
'DATABASE_ERROR',
['tag_id' => $tagId]
);
return $this->sendResponse($errorResponse);
}
// ส่งคำตอบสำเร็จ
$successResponse = $this->errorHandler->createSuccessResponse([
'message' => 'Tag deleted successfully',
'tag_id' => $tagId
], 200, 'Tag deleted successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
// จัดการข้อผิดพลาดที่ไม่คาดคิด
$errorResponse = $this->errorHandler->handleException($e, 'deleteTag');
return $this->sendResponse($errorResponse);
}
}
/**
* Delete album and cleanup photos with enhanced validation and error handling
* @return string JSON response
*/
private function deleteAlbum()
{
try {
// Validate album ID parameter
$albumId = $_GET['albumId'] ?? '';
$validationError = $this->inputValidator->validateAlbumId($albumId);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Validate album exists
$existingAlbum = $this->dataManager->getAlbum($albumId);
if (!$existingAlbum) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album not found',
'ALBUM_NOT_FOUND',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
// Delete physical album directory and all photos
$albumPath = $this->albumsPath.$albumId;
if (is_dir($albumPath)) {
if (!$this->deleteDirectory($albumPath)) {
$this->errorHandler->logWarning(
'Failed to delete album directory completely',
['album_id' => $albumId, 'album_path' => $albumPath]
);
}
}
// Delete album from database
$success = $this->dataManager->deleteAlbum($albumId);
if (!$success) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to delete album from database',
'DATABASE_ERROR',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
$successResponse = $this->errorHandler->createSuccessResponse([
'message' => 'Album deleted successfully',
'albumId' => $albumId
], 200, 'Album deleted successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'deleteAlbum');
return $this->sendResponse($errorResponse);
}
}
/**
* Get all available tags with enhanced error handling
* @return string JSON response
*/
private function getTags()
{
try {
$tags = $this->dataManager->getTags();
$successResponse = $this->errorHandler->createSuccessResponse([
'tags' => $tags
], 200, 'Tags retrieved successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'getTags');
return $this->sendResponse($errorResponse);
}
}
/**
* Create new tag with enhanced validation and error handling
* @return string JSON response
*/
private function createTag()
{
try {
// Get and validate JSON input
$inputRaw = file_get_contents('php://input');
if ($inputRaw === false) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to read request body',
'INVALID_INPUT'
);
return $this->sendResponse($errorResponse);
}
// Validate JSON format
$input = json_decode($inputRaw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid JSON input: '.json_last_error_msg(),
'INVALID_JSON'
);
return $this->sendResponse($errorResponse);
}
// Validate tag creation input
$validationError = $this->inputValidator->validateTagCreation($input);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Sanitize input
$name = trim($input['name']);
$color = isset($input['color']) ? trim($input['color']) : '#3498db';
// Create tag in database
$tagId = $this->dataManager->createTag($name, $color);
// Get the created tag data
$tags = $this->dataManager->getTags();
$createdTag = null;
foreach ($tags as $tag) {
if ($tag['id'] == $tagId) {
$createdTag = $tag;
break;
}
}
if (!$createdTag) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Tag created but failed to retrieve data',
'DATABASE_ERROR',
['tag_id' => $tagId]
);
return $this->sendResponse($errorResponse);
}
$successResponse = $this->errorHandler->createSuccessResponse([
'message' => 'Tag created successfully',
'tag' => $createdTag
], 201, 'Tag created successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'createTag');
return $this->sendResponse($errorResponse);
}
}
/**
* Update album tags with enhanced validation and error handling
* @return string JSON response
*/
private function updateAlbumTags()
{
try {
// Validate album ID parameter
$albumId = $_GET['albumId'] ?? '';
$validationError = $this->inputValidator->validateAlbumId($albumId);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Get and validate JSON input
$inputRaw = file_get_contents('php://input');
if ($inputRaw === false) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to read request body',
'INVALID_INPUT'
);
return $this->sendResponse($errorResponse);
}
// Validate JSON format
$input = json_decode($inputRaw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid JSON input: '.json_last_error_msg(),
'INVALID_JSON'
);
return $this->sendResponse($errorResponse);
}
// Validate album tags update input
$validationError = $this->inputValidator->validateAlbumTagsUpdate($input);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Validate album exists
$existingAlbum = $this->dataManager->getAlbum($albumId);
if (!$existingAlbum) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album not found',
'ALBUM_NOT_FOUND',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
$tagIds = $input['tagIds'];
// Validate all tag IDs exist
$allTags = $this->dataManager->getTags();
$validTagIds = array_column($allTags, 'id');
foreach ($tagIds as $tagId) {
if (!in_array((int) $tagId, $validTagIds)) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid tag ID: '.$tagId,
'TAG_NOT_FOUND',
['tag_id' => $tagId, 'valid_tag_ids' => $validTagIds]
);
return $this->sendResponse($errorResponse);
}
}
// Update album tags
$success = $this->dataManager->updateAlbumTags($albumId, $tagIds);
if (!$success) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to update album tags',
'DATABASE_ERROR',
['album_id' => $albumId, 'tag_ids' => $tagIds]
);
return $this->sendResponse($errorResponse);
}
// Get updated album data with tags
$album = $this->dataManager->getAlbum($albumId);
$albumTags = $this->dataManager->getAlbumTags($albumId);
$successResponse = $this->errorHandler->createSuccessResponse([
'message' => 'Album tags updated successfully',
'album' => $album,
'tags' => $albumTags
], 200, 'Album tags updated successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'updateAlbumTags');
return $this->sendResponse($errorResponse);
}
}
/**
* Upload photos to album with enhanced validation and error handling
* @return string JSON response
*/
private function uploadPhoto()
{
try {
// Validate album ID and file upload parameters
$albumId = $_GET['albumId'] ?? '';
$validationError = $this->inputValidator->validateFileUpload($_FILES, $albumId);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Validate album exists
$existingAlbum = $this->dataManager->getAlbum($albumId);
if (!$existingAlbum) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album not found',
'ALBUM_NOT_FOUND',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
// Get configuration for upload processing
$config = $this->dataManager->getAllConfig();
// Initialize image processor and file upload handler
$imageProcessor = new ImageProcessor($config);
$uploadHandler = new FileUploadHandler($config, $imageProcessor, $this->dataManager);
// Check storage limits before processing
$totalUploadSize = 0;
$files = $_FILES['photos'];
// Calculate total size of files to be uploaded
if (is_array($files['size'])) {
$totalUploadSize = array_sum($files['size']);
} else {
$totalUploadSize = $files['size'];
}
$storageCheck = $uploadHandler->checkStorageLimit($totalUploadSize);
if (!$storageCheck['can_upload']) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Storage limit exceeded. Available space: '.$this->formatBytes($storageCheck['available_space'] ?? 0),
'STORAGE_FULL',
[
'available_space' => $storageCheck['available_space'] ?? 0,
'requested_space' => $totalUploadSize,
'current_usage' => $storageCheck['current_usage'] ?? 0
]
);
return $this->sendResponse($errorResponse);
}
// Process file uploads
$uploadResult = $uploadHandler->handleUpload($albumId, $files);
if (!$uploadResult['success']) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Upload failed: '.$uploadResult['error'],
'IMAGE_PROCESSING_ERROR',
['upload_result' => $uploadResult]
);
return $this->sendResponse($errorResponse);
}
// Prepare response with detailed results
$response = [
'message' => 'Photos uploaded successfully',
'album_id' => $albumId,
'total_files' => $uploadResult['total_files'],
'successful_uploads' => $uploadResult['successful_uploads'],
'failed_uploads' => $uploadResult['total_files'] - $uploadResult['successful_uploads'],
'upload_results' => $uploadResult['results']
];
// Add storage information
if (isset($storageCheck['current_usage'])) {
$response['storage_info'] = [
'current_usage' => $storageCheck['current_usage'],
'max_size' => $storageCheck['max_size'] ?? null,
'usage_percentage' => $storageCheck['usage_percentage'] ?? null
];
}
// Get updated album information
$updatedAlbum = $this->dataManager->getAlbum($albumId);
$response['album'] = $updatedAlbum;
// Trigger RSS feed auto-update if album is RSS-enabled
if (!empty($updatedAlbum['is_rss_enabled'])) {
$this->triggerRSSAutoUpdate();
}
$successResponse = $this->errorHandler->createSuccessResponse(
$response,
200,
'Photos uploaded successfully'
);
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'uploadPhoto');
return $this->sendResponse($errorResponse);
}
}
/**
* Delete a single photo from an album
* @return string JSON response
*/
private function deletePhoto()
{
try {
// Get parameters
$albumId = $_GET['albumId'] ?? '';
$photoFilename = $_GET['filename'] ?? '';
// Validate album ID
$validationError = $this->inputValidator->validateAlbumId($albumId);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Validate filename
if (empty($photoFilename)) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Photo filename is required',
'MISSING_REQUIRED_FIELD',
['field' => 'filename']
);
return $this->sendResponse($errorResponse);
}
// Validate album exists
$existingAlbum = $this->dataManager->getAlbum($albumId);
if (!$existingAlbum) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album not found',
'ALBUM_NOT_FOUND',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
// Get album photos to verify photo exists
$photos = $this->dataManager->getPhotos($albumId);
$photoToDelete = null;
foreach ($photos as $photo) {
if ($photo['filename'] === $photoFilename) {
$photoToDelete = $photo;
break;
}
}
if (!$photoToDelete) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Photo not found in album',
'PHOTO_NOT_FOUND',
['album_id' => $albumId, 'filename' => $photoFilename]
);
return $this->sendResponse($errorResponse);
}
// Delete physical file
$photoPath = $this->albumsPath.$albumId.'/'.$photoFilename;
if (file_exists($photoPath)) {
if (!unlink($photoPath)) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to delete photo file',
'FILE_SYSTEM_ERROR',
['photo_path' => $photoPath]
);
return $this->sendResponse($errorResponse);
}
}
// Remove photo from database
$success = $this->dataManager->deletePhotoByFilename($albumId, $photoFilename);
if (!$success) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to delete photo from database',
'DATABASE_ERROR',
['album_id' => $albumId, 'filename' => $photoFilename]
);
return $this->sendResponse($errorResponse);
}
$successResponse = $this->errorHandler->createSuccessResponse([
'message' => 'Photo deleted successfully',
'albumId' => $albumId,
'filename' => $photoFilename
], 200, 'Photo deleted successfully');
return $this->sendResponse($successResponse);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'deletePhoto');
return $this->sendResponse($errorResponse);
}
}
/**
* Recursively delete directory and all contents
* @param string $dir Directory path
* @return bool Success status
*/
private function deleteDirectory($dir)
{
if (!is_dir($dir)) {
return false;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir.'/'.$file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
return rmdir($dir);
}
/**
* Get all albums with enhanced metadata including title, description, tags, and photo count
* @return mixed JSON response with albums data
*/
private function getAlbums()
{
try {
// Get query parameters for filtering and pagination
$tagFilter = $_GET['tag'] ?? '';
// Input validation for tag filter
if (!empty($tagFilter) && !is_numeric($tagFilter)) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Invalid tag filter. Tag ID must be numeric.',
'VALIDATION_FAILED',
['tag_filter' => $tagFilter]
);
return $this->sendResponse($errorResponse);
}
// Try to get albums from DataManager first
try {
$dbAlbums = $this->dataManager->getAlbums(true);
// Enhance each album with complete metadata
foreach ($dbAlbums as &$album) {
// Extract folder number from title (e.g., "Album 12" -> "12")
$folderNumber = $album['id']; // Default to id
if (preg_match('/Album (\d+)/', $album['title'], $matches)) {
$folderNumber = $matches[1];
}
// Get photos from filesystem to ensure accuracy
$filesystemPhotos = $this->getAlbumPhotos($folderNumber);
$dbPhotos = $this->dataManager->getPhotos($album['id']);
// Use database photos if available, otherwise fallback to filesystem
$photos = !empty($dbPhotos) ? $dbPhotos : array_map(function ($filename) use ($album) {
return [
'id' => null,
'filename' => $filename,
'original_filename' => $filename,
'file_size' => null,
'width' => null,
'height' => null,
'uploaded_at' => null
];
}, $filesystemPhotos);
// Set cover photo (first uploaded image)
$coverPhoto = null;
if (!empty($photos)) {
$coverPhoto = [
'filename' => $photos[0]['filename'],
'path' => "albums/{$folderNumber}/{$photos[0]['filename']}"
];
}
// Ensure all required fields are present
$album['title'] = $album['title'] ?? "Album ".$album['id'];
$album['description'] = $album['description'] ?? '';
$album['photo_count'] = count($photos);
$album['photoCount'] = $album['photo_count']; // Backward compatibility
$album['name'] = $album['title']; // Backward compatibility
$album['cover_photo'] = $coverPhoto;
$album['photos'] = !empty($photos) ? [$photos[0]] : []; // First photo for backward compatibility
// Get detailed tag information
$albumTags = $this->dataManager->getAlbumTags($album['id']);
$album['tag_details'] = $albumTags;
// Ensure tags array exists
$album['tags'] = $album['tags'] ?? [];
// Add timestamps if missing
$album['created_at'] = $album['created_at'] ?? date('c');
$album['updated_at'] = $album['updated_at'] ?? date('c');
$album['is_rss_enabled'] = $album['is_rss_enabled'] ?? false;
}
// Apply tag filtering if specified
if (!empty($tagFilter)) {
$dbAlbums = array_filter($dbAlbums, function ($album) use ($tagFilter) {
return in_array((int) $tagFilter, $album['tags']);
});
}
// Re-index array after filtering and sort by updated_at descending
$dbAlbums = array_values($dbAlbums);
usort($dbAlbums, function ($a, $b) {
return strtotime($b['updated_at']) - strtotime($a['updated_at']);
});
return $this->success([
'albums' => $dbAlbums,
'total_count' => count($dbAlbums),
'filtered_by_tag' => !empty($tagFilter) ? (int) $tagFilter : null
]);
} catch (Exception $e) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Failed to load albums from database: '.$e->getMessage(),
'DATABASE_ERROR',
['error_details' => $e->getMessage()]
);
return $this->sendResponse($errorResponse);
}
} catch (Exception $e) {
$errorResponse = $this->errorHandler->handleException($e, 'getAlbums');
return $this->sendResponse($errorResponse);
}
}
/**
* Get photos for album with enhanced metadata and pagination
* @return mixed JSON response with photos data
*/
private function getPhotos()
{
try {
// Get and validate query parameters
$albumId = $_GET['albumId'] ?? '';
$page = (int) ($_GET['page'] ?? 1);
$limit = (int) ($_GET['limit'] ?? 20);
$sortBy = $_GET['sortBy'] ?? 'uploaded_at'; // uploaded_at, filename, file_size
$sortOrder = $_GET['sortOrder'] ?? 'desc'; // asc, desc
// Validate album ID parameter
$validationError = $this->inputValidator->validateAlbumId($albumId);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Validate pagination parameters
$paginationParams = [
'page' => $page,
'limit' => $limit,
'sortBy' => $sortBy,
'sortOrder' => $sortOrder
];
$validationError = $this->inputValidator->validatePaginationParams($paginationParams);
if ($validationError) {
return $this->sendResponse($validationError);
}
// Get album info to determine correct folder
$album = $this->dataManager->getAlbum($albumId);
if (!$album) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album not found in database',
'ALBUM_NOT_FOUND',
['album_id' => $albumId]
);
return $this->sendResponse($errorResponse);
}
// Extract folder number from title (e.g., "Album 12" -> "12")
$folderNumber = $albumId; // Default to albumId
if (preg_match('/Album (\d+)/', $album['title'], $matches)) {
$folderNumber = $matches[1];
}
// Validate album folder exists
$albumPath = $this->albumsPath.$folderNumber.'/';
if (!is_dir($albumPath)) {
$errorResponse = $this->errorHandler->createErrorResponse(
'Album folder not found',
'ALBUM_FOLDER_NOT_FOUND',
['album_id' => $albumId, 'folder_number' => $folderNumber, 'path' => $albumPath]
);
return $this->sendResponse($errorResponse);
}
// Try to get photos from database first (with metadata)
try {
$dbPhotos = $this->dataManager->getPhotos($albumId);
if (!empty($dbPhotos)) {
// Enhance photos with additional metadata and file system verification
$enhancedPhotos = [];
foreach ($dbPhotos as $photo) {
$filePath = $albumPath.$photo['filename'];
// Verify file still exists on filesystem
if (!file_exists($filePath)) {
continue; // Skip missing files
}
// Get file system metadata
$fileSize = filesize($filePath);
$fileModTime = filemtime($filePath);
// Get image dimensions if not stored in database
$width = $photo['width'];
$height = $photo['height'];
if (empty($width) || empty($height)) {
$imageInfo = getimagesize($filePath);
if ($imageInfo !== false) {
$width = $imageInfo[0];
$height = $imageInfo[1];
}
}
$enhancedPhoto = [
'id' => $photo['id'],
'filename' => $photo['filename'],
'original_filename' => $photo['original_filename'] ?? $photo['filename'],
'file_size' => $photo['file_size'] ?? $fileSize,
'file_size_formatted' => $this->formatBytes($photo['file_size'] ?? $fileSize),
'width' => $width,
'height' => $height,
'aspect_ratio' => ($width && $height) ? round($width / $height, 2) : null,
'uploaded_at' => $photo['uploaded_at'],
'uploaded_at_formatted' => $photo['uploaded_at'] ? date('Y-m-d H:i:s', strtotime($photo['uploaded_at'])) : null,
'file_modified_at' => date('c', $fileModTime),
'path' => "albums/{$albumId}/{$photo['filename']}",
'thumbnail_path' => "albums/{$albumId}/thumb_{$photo['filename']}",
'has_thumbnail' => file_exists($albumPath.'thumb_'.$photo['filename'])
];
$enhancedPhotos[] = $enhancedPhoto;
}
// Sort photos based on parameters
usort($enhancedPhotos, function ($a, $b) use ($sortBy, $sortOrder) {
$valueA = $a[$sortBy] ?? '';
$valueB = $b[$sortBy] ?? '';
// Handle different data types
if (in_array($sortBy, ['file_size', 'width', 'height'])) {
$valueA = (int) $valueA;
$valueB = (int) $valueB;
} elseif ($sortBy === 'uploaded_at') {
$valueA = strtotime($valueA ?: '1970-01-01');
$valueB = strtotime($valueB ?: '1970-01-01');
}
$comparison = $valueA <=> $valueB;
return $sortOrder === 'desc' ? -$comparison : $comparison;
});
// Apply pagination
$totalPhotos = count($enhancedPhotos);
$offset = ($page - 1) * $limit;
$paginatedPhotos = array_slice($enhancedPhotos, $offset, $limit);
return $this->success([
'photos' => $paginatedPhotos,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total_photos' => $totalPhotos,
'total_pages' => ceil($totalPhotos / $limit),
'has_more' => $offset + $limit < $totalPhotos,
'has_previous' => $page > 1
],
'sorting' => [
'sort_by' => $sortBy,
'sort_order' => $sortOrder
],
'album' => [
'id' => $album['id'],
'title' => $album['title'],
'photo_count' => $totalPhotos
]
]);
}
} catch (Exception $e) {
// Log error but continue with filesystem fallback
error_log('DataManager getPhotos error: '.$e->getMessage());
}
// Fallback to filesystem-based photo discovery with basic metadata
$filesystemPhotos = $this->getAlbumPhotos($albumId);
$enhancedPhotos = [];
foreach ($filesystemPhotos as $filename) {
$filePath = $albumPath.$filename;
if (!file_exists($filePath)) {
continue;
}
$fileSize = filesize($filePath);
$fileModTime = filemtime($filePath);
// Get image dimensions
$width = null;
$height = null;
$imageInfo = getimagesize($filePath);
if ($imageInfo !== false) {
$width = $imageInfo[0];
$height = $imageInfo[1];
}
$enhancedPhoto = [
'id' => null, // No database ID for filesystem-only photos
'filename' => $filename,
'original_filename' => $filename,
'file_size' => $fileSize,
'file_size_formatted' => $this->formatBytes($fileSize),
'width' => $width,
'height' => $height,
'aspect_ratio' => ($width && $height) ? round($width / $height, 2) : null,
'uploaded_at' => null,
'uploaded_at_formatted' => null,
'file_modified_at' => date('c', $fileModTime),
'path' => "albums/{$albumId}/{$filename}",
'thumbnail_path' => "albums/{$albumId}/thumb_{$filename}",
'has_thumbnail' => file_exists($albumPath.'thumb_'.$filename)
];
$enhancedPhotos[] = $enhancedPhoto;
}
// Sort photos (filesystem fallback uses file modification time for uploaded_at)
usort($enhancedPhotos, function ($a, $b) use ($sortBy, $sortOrder) {
$valueA = $a[$sortBy] ?? '';
$valueB = $b[$sortBy] ?? '';
// Handle sorting by uploaded_at for filesystem photos
if ($sortBy === 'uploaded_at') {
$valueA = strtotime($a['file_modified_at']);
$valueB = strtotime($b['file_modified_at']);
} elseif (in_array($sortBy, ['file_size', 'width', 'height'])) {
$valueA = (int) $valueA;
$valueB = (int) $valueB;
}
$comparison = $valueA <=> $valueB;
return $sortOrder === 'desc' ? -$comparison : $comparison;
});
// Apply pagination
$totalPhotos = count($enhancedPhotos);
$offset = ($page - 1) * $limit;
$paginatedPhotos = array_slice($enhancedPhotos, $offset, $limit);
return $this->success([
'photos' => $paginatedPhotos,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total_photos' => $totalPhotos,
'total_pages' => ceil($totalPhotos / $limit),
'has_more' => $offset + $limit < $totalPhotos,
'has_previous' => $page > 1
],
'sorting' => [
'sort_by' => $sortBy,
'sort_order' => $sortOrder
],
'album' => [
'id' => $albumId,
'title' => "Album {$albumId}",
'photo_count' => $totalPhotos
]
]);
} catch (Exception $e) {
return $this->error('Error loading photos: '.$e->getMessage());
}
}
/**
* @param $albumId
* @return mixed
*/
private function getAlbumPhotos($albumId)
{
$albumPath = $this->albumsPath.$albumId.'/';
$photos = [];
if (!is_dir($albumPath)) {
return $photos;
}
$files = scandir($albumPath);
foreach ($files as $file) {
// Skip directories, thumbnail files, and non-image files
if ($file === '.' || $file === '..' ||
is_dir($albumPath.$file) ||
strpos($file, 'thumb_') === 0 ||
!$this->isImageFile($file)) {
continue;
}
$photos[] = $file;
}
// Sort photos naturally (1.jpg, 2.jpg, 10.jpg, etc.)
natsort($photos);
return array_values($photos);
}
/**
* @param $filename
*/
private function isImageFile($filename)
{
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($extension, $allowedExtensions);
}
/**
* Return success response with proper structure
* @param array $data Response data
* @param int $httpCode HTTP status code
* @return string JSON response
*/
private function success($data, $httpCode = 200)
{
http_response_code($httpCode);
// Ensure consistent response structure
$response = [
'success' => true,
'timestamp' => date('c'),
'data' => $data
];
// Merge data at root level for backward compatibility
return json_encode($response + $data);
}
/**
* Return error response with proper structure and logging
* @param string $message Error message
* @param int $httpCode HTTP status code
* @param string $errorCode Internal error code
* @param array $details Additional error details
* @return string JSON response
*/
private function error($message, $httpCode = 400, $errorCode = null, $details = [])
{
http_response_code($httpCode);
// Log error for debugging
$logMessage = "API Error: {$message}";
if ($errorCode) {
$logMessage .= " (Code: {$errorCode})";
}
if (!empty($details)) {
$logMessage .= " Details: ".json_encode($details);
}
error_log($logMessage);
$response = [
'success' => false,
'error' => $message,
'timestamp' => date('c')
];
if ($errorCode) {
$response['error_code'] = $errorCode;
}
if (!empty($details)) {
$response['details'] = $details;
}
return json_encode($response);
}
/**
* Format bytes to human readable format
* @param int $bytes Number of bytes
* @return string Formatted string
*/
private function formatBytes($bytes)
{
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2).' '.$units[$pow];
}
/**
* Update RSS configuration settings
* @return mixed JSON response
*/
private function updateRSSSettings()
{
try {
// Get JSON input
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
return $this->error('Invalid JSON input');
}
// Define allowed RSS configuration keys
$allowedKeys = [
'rss_title',
'rss_description',
'rss_max_items',
'rss_base_url'
];
$updatedSettings = [];
foreach ($input as $key => $value) {
if (in_array($key, $allowedKeys)) {
// Validate specific settings
switch ($key) {
case 'rss_title':
case 'rss_description':
$value = trim($value);
if (empty($value)) {
return $this->error("$key cannot be empty");
}
break;
case 'rss_max_items':
$value = (int) $value;
if ($value < 1 || $value > 200) {
return $this->error('rss_max_items must be between 1 and 200');
}
break;
case 'rss_base_url':
$value = trim($value);
if (empty($value) || !filter_var($value, FILTER_VALIDATE_URL)) {
return $this->error('rss_base_url must be a valid URL');
}
break;
}
// Update configuration
$this->dataManager->setConfig($key, $value);
$updatedSettings[$key] = $value;
}
}
if (empty($updatedSettings)) {
return $this->error('No valid RSS settings provided');
}
return $this->success([
'message' => 'RSS settings updated successfully',
'updated_settings' => $updatedSettings
]);
} catch (Exception $e) {
return $this->error('Failed to update RSS settings: '.$e->getMessage());
}
}
/**
* Get configuration settings
* @return mixed JSON response with configuration data
*/
private function getConfig()
{
try {
// Get query parameter to filter specific config keys
$keys = $_GET['keys'] ?? '';
$includeStorageInfo = isset($_GET['includeStorageInfo']) ? (bool) $_GET['includeStorageInfo'] : true;
// Get all configuration
$config = $this->dataManager->getAllConfig();
// Filter by specific keys if requested
if (!empty($keys)) {
$requestedKeys = array_map('trim', explode(',', $keys));
$filteredConfig = [];
foreach ($requestedKeys as $key) {
if (array_key_exists($key, $config)) {
$filteredConfig[$key] = $config[$key];
}
}
$config = $filteredConfig;
}
// Add storage usage information if requested
$storageInfo = null;
if ($includeStorageInfo) {
$storageInfo = $this->getStorageUsageInfo();
}
// Organize configuration into logical groups
$organizedConfig = [
'upload' => [
'max_file_size' => $config['upload_max_file_size'] ?? 10485760,
'max_file_size_formatted' => $this->formatBytes($config['upload_max_file_size'] ?? 10485760),
'allowed_types' => $config['upload_allowed_types'] ?? []
],
'image_processing' => [
'webp_quality' => $config['image_webp_quality'] ?? 80,
'max_width' => $config['image_max_width'] ?? 1000,
'max_height' => $config['image_max_height'] ?? 1000,
'thumbnail_size' => $config['image_thumbnail_size'] ?? 300
],
'rss' => [
'title' => $config['rss_title'] ?? 'Photo Gallery RSS Feed',
'description' => $config['rss_description'] ?? 'Latest photos from selected albums',
'max_items' => $config['rss_max_items'] ?? 50,
'base_url' => $config['rss_base_url'] ?? $this->getBaseUrl()
],
'storage' => [
'max_total_size' => $config['storage_max_total_size'] ?? 1073741824,
'max_total_size_formatted' => $this->formatBytes($config['storage_max_total_size'] ?? 1073741824),
'cleanup_enabled' => $config['storage_cleanup_enabled'] ?? true
]
];
$response = [
'config' => $organizedConfig,
'raw_config' => $config
];
if ($storageInfo) {
$response['storage_info'] = $storageInfo;
}
return $this->success($response);
} catch (Exception $e) {
return $this->error('Failed to get configuration: '.$e->getMessage());
}
}
/**
* Update configuration settings
* @return mixed JSON response
*/
private function updateConfig()
{
try {
// Get JSON input
$inputRaw = file_get_contents('php://input');
if ($inputRaw === false) {
return $this->error('Failed to read request body', 400, 'INPUT_READ_ERROR');
}
$input = json_decode($inputRaw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return $this->error('Invalid JSON input: '.json_last_error_msg(), 400, 'INVALID_JSON');
}
if (!is_array($input)) {
return $this->error('Request body must be a JSON object', 400, 'INVALID_INPUT_TYPE');
}
// Define allowed configuration keys with validation rules
$allowedKeys = [
'upload_max_file_size' => [
'type' => 'integer',
'min' => 1048576, // 1MB minimum
'max' => 104857600, // 100MB maximum
'description' => 'Maximum file size for uploads (bytes)'
],
'upload_allowed_types' => [
'type' => 'array',
'allowed_values' => ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff', 'image/webp'],
'description' => 'Allowed MIME types for uploads'
],
'image_webp_quality' => [
'type' => 'integer',
'min' => 1,
'max' => 100,
'description' => 'WebP compression quality (1-100)'
],
'image_max_width' => [
'type' => 'integer',
'min' => 100,
'max' => 5000,
'description' => 'Maximum image width in pixels'
],
'image_max_height' => [
'type' => 'integer',
'min' => 100,
'max' => 5000,
'description' => 'Maximum image height in pixels'
],
'image_thumbnail_size' => [
'type' => 'integer',
'min' => 50,
'max' => 500,
'description' => 'Thumbnail size in pixels'
],
'rss_title' => [
'type' => 'string',
'max_length' => 255,
'description' => 'RSS feed title'
],
'rss_description' => [
'type' => 'string',
'max_length' => 1000,
'description' => 'RSS feed description'
],
'rss_max_items' => [
'type' => 'integer',
'min' => 1,
'max' => 200,
'description' => 'Maximum items in RSS feed'
],
'rss_base_url' => [
'type' => 'url',
'description' => 'Base URL for RSS feed links'
],
'storage_max_total_size' => [
'type' => 'integer',
'min' => 104857600, // 100MB minimum
'max' => 10737418240, // 10GB maximum
'description' => 'Maximum total storage size (bytes)'
],
'storage_cleanup_enabled' => [
'type' => 'boolean',
'description' => 'Enable automatic cleanup of orphaned files'
]
];
$updatedSettings = [];
$validationErrors = [];
foreach ($input as $key => $value) {
if (!array_key_exists($key, $allowedKeys)) {
$validationErrors[] = "Unknown configuration key: $key";
continue;
}
$rules = $allowedKeys[$key];
// Validate based on type
switch ($rules['type']) {
case 'integer':
if (!is_numeric($value)) {
$validationErrors[] = "$key must be a number";
continue 2;
}
$value = (int) $value;
if (isset($rules['min']) && $value < $rules['min']) {
$validationErrors[] = "$key must be at least {$rules['min']}";
continue 2;
}
if (isset($rules['max']) && $value > $rules['max']) {
$validationErrors[] = "$key must not exceed {$rules['max']}";
continue 2;
}
break;
case 'string':
if (!is_string($value)) {
$validationErrors[] = "$key must be a string";
continue 2;
}
$value = trim($value);
if (empty($value)) {
$validationErrors[] = "$key cannot be empty";
continue 2;
}
if (isset($rules['max_length']) && strlen($value) > $rules['max_length']) {
$validationErrors[] = "$key cannot exceed {$rules['max_length']} characters";
continue 2;
}
break;
case 'boolean':
$value = (bool) $value;
break;
case 'array':
if (!is_array($value)) {
$validationErrors[] = "$key must be an array";
continue 2;
}
if (isset($rules['allowed_values'])) {
foreach ($value as $item) {
if (!in_array($item, $rules['allowed_values'])) {
$validationErrors[] = "$key contains invalid value: $item";
continue 3;
}
}
}
break;
case 'url':
if (!is_string($value)) {
$validationErrors[] = "$key must be a string";
continue 2;
}
$value = trim($value);
if (empty($value) || !filter_var($value, FILTER_VALIDATE_URL)) {
$validationErrors[] = "$key must be a valid URL";
continue 2;
}
break;
}
// Additional validation for storage limits
if ($key === 'storage_max_total_size') {
$currentUsage = $this->getStorageUsageInfo();
if ($currentUsage && isset($currentUsage['current_usage']) && $value < $currentUsage['current_usage']) {
$validationErrors[] = "storage_max_total_size cannot be less than current usage ({$this->formatBytes($currentUsage['current_usage'])})";
continue;
}
}
// Update configuration
$this->dataManager->setConfig($key, $value);
$updatedSettings[$key] = $value;
}
if (!empty($validationErrors)) {
return $this->error('Configuration validation failed', 400, 'VALIDATION_ERROR', [
'validation_errors' => $validationErrors
]);
}
if (empty($updatedSettings)) {
return $this->error('No valid configuration settings provided', 400, 'NO_VALID_SETTINGS');
}
// Get updated storage info if storage settings were changed
$storageInfo = null;
if (array_intersect_key($updatedSettings, array_flip(['storage_max_total_size', 'storage_cleanup_enabled']))) {
$storageInfo = $this->getStorageUsageInfo();
}
$response = [
'message' => 'Configuration updated successfully',
'updated_settings' => $updatedSettings,
'updated_count' => count($updatedSettings)
];
if ($storageInfo) {
$response['storage_info'] = $storageInfo;
}
return $this->success($response);
} catch (Exception $e) {
return $this->error('Failed to update configuration: '.$e->getMessage());
}
}
/**
* Get storage usage information and enforce limits
* @return array Storage usage data
*/
private function getStorageUsageInfo()
{
try {
$albumsPath = $this->albumsPath;
$maxSize = $this->dataManager->getConfig('storage_max_total_size') ?? 1073741824; // 1GB default
// Calculate current storage usage
$currentUsage = 0;
$fileCount = 0;
$albumCount = 0;
if (is_dir($albumsPath)) {
$dirs = scandir($albumsPath);
if ($dirs !== false) {
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
$albumPath = $albumsPath.$dir;
if (is_dir($albumPath)) {
$albumCount++;
$files = scandir($albumPath);
if ($files !== false) {
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$filePath = $albumPath.'/'.$file;
if (is_file($filePath)) {
$currentUsage += filesize($filePath);
$fileCount++;
}
}
}
}
}
}
}
$usagePercentage = $maxSize > 0 ? round(($currentUsage / $maxSize) * 100, 2) : 0;
$availableSpace = max(0, $maxSize - $currentUsage);
return [
'current_usage' => $currentUsage,
'current_usage_formatted' => $this->formatBytes($currentUsage),
'max_size' => $maxSize,
'max_size_formatted' => $this->formatBytes($maxSize),
'available_space' => $availableSpace,
'available_space_formatted' => $this->formatBytes($availableSpace),
'usage_percentage' => $usagePercentage,
'file_count' => $fileCount,
'album_count' => $albumCount,
'is_near_limit' => $usagePercentage >= 80,
'is_over_limit' => $usagePercentage >= 100,
'can_upload' => $usagePercentage < 95 // Leave 5% buffer
];
} catch (Exception $e) {
error_log('Storage usage calculation error: '.$e->getMessage());
return [
'current_usage' => 0,
'current_usage_formatted' => '0 B',
'max_size' => $maxSize ?? 1073741824,
'max_size_formatted' => $this->formatBytes($maxSize ?? 1073741824),
'available_space' => $maxSize ?? 1073741824,
'available_space_formatted' => $this->formatBytes($maxSize ?? 1073741824),
'usage_percentage' => 0,
'file_count' => 0,
'album_count' => 0,
'is_near_limit' => false,
'is_over_limit' => false,
'can_upload' => true,
'error' => 'Failed to calculate storage usage'
];
}
}
/**
* Get storage statistics and usage information
* @return mixed JSON response with storage statistics
*/
private function getStorageStats()
{
try {
$stats = $this->dataManager->getStorageStats($this->albumsPath);
// Format file sizes for display
$stats['total_size_formatted'] = $this->formatBytes($stats['total_size']);
$stats['max_storage_size_formatted'] = $this->formatBytes($stats['max_storage_size']);
$stats['available_space_formatted'] = $this->formatBytes($stats['available_space']);
// Format largest files
foreach ($stats['largest_files'] as &$file) {
$file['size_formatted'] = $this->formatBytes($file['size']);
}
// Format album sizes
foreach ($stats['album_sizes'] as &$album) {
$album['size_formatted'] = $this->formatBytes($album['size']);
}
// Format orphaned files
foreach ($stats['orphaned_files'] as &$file) {
$file['size_formatted'] = $this->formatBytes($file['size']);
}
// Format file types
foreach ($stats['file_types'] as $ext => &$typeInfo) {
$typeInfo['size_formatted'] = $this->formatBytes($typeInfo['size']);
}
return $this->success([
'message' => 'Storage statistics retrieved successfully',
'stats' => $stats
]);
} catch (Exception $e) {
return $this->error('Failed to get storage statistics: '.$e->getMessage());
}
}
/**
* Clean up orphaned files (files not referenced in database)
* @return mixed JSON response with cleanup results
*/
private function cleanupOrphanedFiles()
{
try {
// Get JSON input
$input = json_decode(file_get_contents('php://input'), true);
$dryRun = isset($input['dry_run']) ? (bool) $input['dry_run'] : false;
$result = $this->dataManager->cleanupOrphanedFiles($this->albumsPath, $dryRun);
// Format file sizes for display
$result['space_freed_formatted'] = $this->formatBytes($result['space_freed']);
foreach ($result['deleted_files'] as &$file) {
$file['size_formatted'] = $this->formatBytes($file['size']);
}
foreach ($result['deleted_thumbnails'] as &$file) {
$file['size_formatted'] = $this->formatBytes($file['size']);
}
$message = $dryRun
? 'Dry run completed - no files were actually deleted'
: 'Orphaned files cleanup completed successfully';
return $this->success([
'message' => $message,
'dry_run' => $dryRun,
'cleanup_result' => $result
]);
} catch (Exception $e) {
return $this->error('Failed to cleanup orphaned files: '.$e->getMessage());
}
}
/**
* Generate missing thumbnails for photos
* @return mixed JSON response with thumbnail generation results
*/
private function generateThumbnails()
{
try {
// Get configuration for image processing
$config = $this->dataManager->getAllConfig();
$imageProcessor = new ImageProcessor($config);
$result = $this->dataManager->generateMissingThumbnails($this->albumsPath, $imageProcessor);
// Format file sizes for display
foreach ($result['generated_thumbnails'] as &$thumbnail) {
$thumbnail['size_formatted'] = $this->formatBytes($thumbnail['size']);
}
return $this->success([
'message' => 'Thumbnail generation completed successfully',
'generation_result' => $result
]);
} catch (Exception $e) {
return $this->error('Failed to generate thumbnails: '.$e->getMessage());
}
}
/**
* Get storage usage by album
* @return mixed JSON response with album storage usage
*/
private function getAlbumStorageUsage()
{
try {
$usage = $this->dataManager->getAlbumStorageUsage($this->albumsPath);
return $this->success([
'message' => 'Album storage usage retrieved successfully',
'album_usage' => $usage
]);
} catch (Exception $e) {
return $this->error('Failed to get album storage usage: '.$e->getMessage());
}
}
/**
* Send JSON response with proper HTTP status code
* @param array $response Response data
* @return string JSON response
*/
private function sendResponse($response)
{
// Set HTTP status code if specified
if (isset($response['error']['http_status'])) {
http_response_code($response['error']['http_status']);
} elseif (isset($response['meta']['http_status'])) {
http_response_code($response['meta']['http_status']);
}
return json_encode($response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* Trigger RSS feed auto-update mechanism
* Called when photos are added or albums are modified
* @return bool Success status
*/
private function triggerRSSAutoUpdate()
{
try {
// Get base URL from configuration
$config = $this->dataManager->getAllConfig();
$baseUrl = $config['rss_base_url'] ?? $this->getBaseUrl();
// Create RSS generator
$rssGenerator = new RSSGenerator($this->dataManager, $baseUrl);
// Trigger auto-update
return $rssGenerator->triggerAutoUpdate();
} catch (Exception $e) {
$this->errorHandler->logError(
'RSS auto-update failed: '.$e->getMessage(),
'RSS_AUTO_UPDATE_ERROR',
['exception' => get_class($e)],
['method' => 'triggerRSSAutoUpdate']
);
return false;
}
}
/**
* Get base URL from current request
* @return string Base URL
*/
private function getBaseUrl()
{
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$path = dirname($_SERVER['SCRIPT_NAME']);
return $protocol.$host.rtrim($path, '/').'/';
}
/**
* Check authentication status
* @return string JSON response
*/
private function checkAuthStatus()
{
$userInfo = $this->auth->getCurrentUser();
$successResponse = $this->errorHandler->createSuccessResponse($userInfo);
return $this->sendResponse($successResponse);
}
}
// Handle the request
$api = new PhotoGalleryAPI();
echo $api->handleRequest();