($a['modified'] ?? 0); }); jsonResponse(true, [ 'items' => $items, 'folders' => $folders, // What the server lets through and what Raiju allows, so the library // can check before sending and say which limit applies 'limits' => raijuMediaUploadLimits(), 'types' => array_values(array_map(function($config) { return [ 'type' => $config['type'], 'label' => $config['label'], 'extensions' => $config['extensions'], 'maxSize' => $config['maxSize'], ]; }, getMediaConfig())), ]); break; case 'upload-media': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? 'image'); $config = getMediaConfig($type); if (!$config) { jsonResponse(false, null, 'Invalid media type'); } $field = $config['field']; if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) { $field = isset($_FILES['file']) ? 'file' : $field; } if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) { // A request above post_max_size reaches PHP with an empty $_FILES, so an // empty upload with a Content-Length counts as "too large" as well $code = $_FILES[$field]['error'] ?? null; $contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); if ($code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE || (empty($_FILES) && $contentLength > raijuUploadServerLimit())) { $serverMb = raijuMegabytes(raijuUploadServerLimit()); jsonResponse(false, ['serverLimitBytes' => raijuUploadServerLimit()], sprintf('File too large: the server accepts uploads up to %s MB (upload_max_filesize / post_max_size)', $serverMb)); } jsonResponse(false, null, $code === UPLOAD_ERR_PARTIAL ? 'Upload error: the file arrived only partially' : 'Upload error'); } $file = $_FILES[$field]; $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if (!in_array($extension, $config['extensions'], true)) { jsonResponse(false, null, 'Invalid file extension'); } $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $file['tmp_name']); unset($finfo); if (!in_array($mimeType, $config['mimeTypes'], true)) { jsonResponse(false, null, 'Invalid file type'); } if ($file['size'] > $config['maxSize']) { jsonResponse(false, null, sprintf('File too large: the limit for %s is %s MB', $config['type'], raijuMegabytes((int)$config['maxSize']))); } $originalName = pathinfo($file['name'], PATHINFO_FILENAME); $safeName = preg_replace('/[^a-z0-9\-_]/i', '-', $originalName); $safeName = preg_replace('/-+/', '-', $safeName); $safeName = trim($safeName, '-'); if ($safeName === '') { $safeName = $config['fallbackName'] . '-' . time(); } $folder = trim((string)($_POST['folder'] ?? '')); if ($folder !== '' && !validateMediaFolderName($folder)) { jsonResponse(false, null, 'Invalid folder name'); } $targetBase = $config['path'] . ($folder !== '' ? $folder . '/' : ''); $targetPublicBase = $config['publicPath'] . ($folder !== '' ? $folder . '/' : ''); if (!is_dir($targetBase)) { mkdir($targetBase, 0755, true); } if ($type === 'image' && in_array($mimeType, ['image/jpeg', 'image/png', 'image/webp'], true)) { // Provenance sits in metadata that re-encoding drops: read it from the upload $aiLabel = raijuDetectAiLabel($file['tmp_name']); // The web version and the original share a stem; it must be free in both places $stem = pathinfo(uniqueMediaFilename($targetBase, $safeName . '.' . $extension), PATHINFO_FILENAME); while (file_exists($targetBase . $stem . '.webp') || raijuMediaOriginalFor(($folder !== '' ? $folder . '/' : '') . $stem . '.webp') !== null) { $stem = pathinfo(uniqueMediaFilename($targetBase, $stem . '-1.' . $extension), PATHINFO_FILENAME); } $stored = raijuMediaStoreImage($file['tmp_name'], $targetBase, $stem, $extension, $mimeType, 'move_uploaded_file'); if ($stored === null) { jsonResponse(false, null, 'Error saving'); } $relativeName = ($folder !== '' ? $folder . '/' : '') . $stored['name']; if ($aiLabel !== '') { raijuSetMediaAiLabel($type, $relativeName, $aiLabel); } jsonResponse(true, [ 'type' => $type, 'name' => $relativeName, 'path' => $targetPublicBase . $stored['name'], 'aiLabel' => $aiLabel, 'webVersion' => $stored['webVersion'], 'original' => raijuMediaOriginalFor($relativeName), ], 'Media uploaded'); } $filename = uniqueMediaFilename($targetBase, $safeName . '.' . $extension); if (move_uploaded_file($file['tmp_name'], $targetBase . $filename)) { if (strtolower($extension) === 'svg' && !raijuSanitizeSvgFile($targetBase . $filename)) { @unlink($targetBase . $filename); jsonResponse(false, null, 'SVG could not be processed safely'); } $relativeName = ($folder !== '' ? $folder . '/' : '') . $filename; $aiLabel = in_array($type, ['image', 'video'], true) ? raijuDetectAiLabel($targetBase . $filename) : ''; if ($aiLabel !== '') { raijuSetMediaAiLabel($type, $relativeName, $aiLabel); } jsonResponse(true, [ 'type' => $type, 'name' => $relativeName, 'path' => $targetPublicBase . $filename, 'aiLabel' => $aiLabel, 'webVersion' => null, ], 'Media uploaded'); } jsonResponse(false, null, 'Error saving'); break; case 'create-media-folder': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? 'image'); $folder = trim((string)($_POST['folder'] ?? '')); $config = getMediaConfig($type); if (!$config || !validateMediaFolderName($folder)) { jsonResponse(false, null, 'Invalid folder name'); } $path = $config['path'] . $folder; if (is_dir($path)) { jsonResponse(true, ['folder' => $folder], 'Folder exists'); } if (file_exists($path)) { jsonResponse(false, null, 'A file with this name already exists'); } if (mkdir($path, 0755, true)) { jsonResponse(true, ['folder' => $folder], 'Folder created'); } jsonResponse(false, null, 'Error creating folder'); break; case 'delete-media-folder': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? 'image'); $folder = trim((string)($_POST['folder'] ?? '')); $config = getMediaConfig($type); if (!$config || !validateMediaFolderName($folder)) { jsonResponse(false, null, 'Invalid folder name'); } $path = $config['path'] . $folder; if (!is_dir($path) || is_link($path)) { jsonResponse(false, null, 'Folder not found'); } $contents = array_values(array_diff(scandir($path) ?: [], ['.', '..'])); // OS metadata files do not count as content and are removed with the folder. $junkFiles = ['.DS_Store', 'Thumbs.db', 'desktop.ini']; $realContents = array_values(array_diff($contents, $junkFiles)); if (count($realContents) > 0) { jsonResponse(false, null, 'Folder must be empty before it can be deleted'); } foreach (array_intersect($contents, $junkFiles) as $junkFile) { @unlink($path . '/' . $junkFile); } if (rmdir($path)) { jsonResponse(true, ['folder' => $folder], 'Folder deleted'); } jsonResponse(false, null, 'Error deleting folder'); break; case 'move-media': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? 'image'); $filename = $_POST['filename'] ?? ''; $targetFolder = trim((string)($_POST['folder'] ?? '')); $config = getMediaConfig($type); if (!$config || !validateMediaFilename($filename, $type)) { jsonResponse(false, null, 'Invalid media file'); } if ($targetFolder !== '' && !validateMediaFolderName($targetFolder)) { jsonResponse(false, null, 'Invalid folder name'); } $sourcePath = $config['path'] . $filename; if (!is_file($sourcePath)) { jsonResponse(false, null, 'File not found'); } $basename = basename($filename); $targetRelative = ($targetFolder !== '' ? $targetFolder . '/' : '') . $basename; if ($targetRelative === $filename) { jsonResponse(true, [ 'type' => $type, 'name' => $filename, 'path' => $config['publicPath'] . $filename, ], 'Media already in folder'); } $targetDirectory = $config['path'] . ($targetFolder !== '' ? $targetFolder : ''); if (!is_dir($targetDirectory) && !mkdir($targetDirectory, 0755, true)) { jsonResponse(false, null, 'Error creating target folder'); } $targetRelative = uniqueMediaRelativePath($config['path'], $targetRelative); if (rename($sourcePath, $config['path'] . $targetRelative)) { raijuMoveMediaMeta($type, $filename, $type, $targetRelative); if ($type === 'image') raijuMediaOriginalRelocate($filename, false, $targetRelative, false); jsonResponse(true, [ 'type' => $type, 'name' => $targetRelative, 'path' => $config['publicPath'] . $targetRelative, ], 'Media moved'); } jsonResponse(false, null, 'Error moving media'); break; case 'rename-media': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? 'image'); $filename = $_POST['filename'] ?? ''; $newName = trim((string)($_POST['newName'] ?? '')); $scanReferences = ($_POST['scanReferences'] ?? '0') === '1'; $confirmReferences = ($_POST['confirmReferences'] ?? '0') === '1'; $config = getMediaConfig($type); if (!$config || !validateMediaFilename($filename, $type)) { jsonResponse(false, null, 'Invalid media file'); } if (!validateMediaRenameBasename($newName, $type)) { jsonResponse(false, null, 'Invalid file name'); } $oldBasename = basename($filename); $oldExt = normalizeRenameExtension(pathinfo($oldBasename, PATHINFO_EXTENSION)); $newExt = normalizeRenameExtension(pathinfo($newName, PATHINFO_EXTENSION)); if ($oldExt === '' || $newExt === '' || $oldExt !== $newExt) { jsonResponse(false, null, 'File extension cannot be changed'); } $folder = trim(str_replace('\\', '/', dirname($filename)), '.'); $targetRelative = ($folder !== '' ? $folder . '/' : '') . $newName; if (!validateMediaFilename($targetRelative, $type)) { jsonResponse(false, null, 'Invalid file name'); } if ($targetRelative === $filename) { jsonResponse(true, [ 'type' => $type, 'name' => $filename, 'path' => $config['publicPath'] . $filename, 'references' => [], ], 'Media already has this name'); } $sourcePath = $config['path'] . $filename; $targetPath = $config['path'] . $targetRelative; if (!is_file($sourcePath) || is_link($sourcePath)) { jsonResponse(false, null, 'File not found'); } if (file_exists($targetPath)) { jsonResponse(false, null, 'A file with this name already exists'); } $references = []; if ($scanReferences) { $referencesByPath = []; foreach ([ $oldBasename, $filename, ltrim($config['publicPath'], './') . $filename, '/' . ltrim($config['publicPath'], './') . $filename, ] as $needle) { foreach (findMediaJsonReferences($needle) as $match) { $key = $match['file']; $referencesByPath[$key] = $match; } } $references = array_values($referencesByPath); if ($references && !$confirmReferences) { jsonResponse(false, [ 'requiresConfirmation' => true, 'references' => $references, 'oldName' => $filename, 'newName' => $targetRelative, ], 'References found'); } } if (rename($sourcePath, $targetPath)) { raijuMoveMediaMeta($type, $filename, $type, $targetRelative); if ($type === 'image') raijuMediaOriginalRelocate($filename, false, $targetRelative, false); jsonResponse(true, [ 'type' => $type, 'name' => $targetRelative, 'path' => $config['publicPath'] . $targetRelative, 'oldName' => $filename, 'references' => $references, ], 'Media renamed'); } jsonResponse(false, null, 'Error renaming media'); break; case 'set-media-ai-label': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? 'image'); $filename = $_POST['filename'] ?? ''; $aiLabel = (string)($_POST['label'] ?? ''); $config = getMediaConfig($type); if (!$config || !in_array($type, ['image', 'video'], true) || !validateMediaFilename($filename, $type)) { jsonResponse(false, null, 'Invalid media file'); } if ($aiLabel !== '' && !in_array($aiLabel, RAIJU_AI_LABEL_KINDS, true)) { jsonResponse(false, null, 'Invalid AI label'); } if (!is_file($config['path'] . $filename)) { jsonResponse(false, null, 'File not found'); } if (!raijuSetMediaAiLabel($type, $filename, $aiLabel)) { jsonResponse(false, null, 'Error saving'); } jsonResponse(true, ['type' => $type, 'name' => $filename, 'aiLabel' => $aiLabel], 'AI label saved'); break; case 'delete-media': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? ''); $filename = $_POST['filename'] ?? ''; $config = getMediaConfig($type); if (!$config || !validateMediaFilename($filename, $type)) { jsonResponse(false, null, 'Invalid media file'); } $sourcePath = $config['path'] . $filename; if (!file_exists($sourcePath)) { jsonResponse(false, null, 'File not found'); } if (!is_dir($config['trashPath'])) { mkdir($config['trashPath'], 0755, true); } $targetFilename = uniqueMediaRelativePath($config['trashPath'], $filename); $targetDirectory = dirname($config['trashPath'] . $targetFilename); if (!is_dir($targetDirectory)) { mkdir($targetDirectory, 0755, true); } if (rename($sourcePath, $config['trashPath'] . $targetFilename)) { raijuMoveMediaMeta($type, $filename, $type . '-trash', $targetFilename); if ($type === 'image') raijuMediaOriginalRelocate($filename, false, $targetFilename, true); jsonResponse(true, null, 'Media moved to trash'); } jsonResponse(false, null, 'Error moving'); break; case 'restore-media': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? ''); $filename = $_POST['filename'] ?? ''; $config = getMediaConfig($type); if (!$config || !validateMediaFilename($filename, $type)) { jsonResponse(false, null, 'Invalid media file'); } $sourcePath = $config['trashPath'] . $filename; if (!file_exists($sourcePath)) { jsonResponse(false, null, 'File not found'); } $targetFilename = uniqueMediaRelativePath($config['path'], $filename); $targetDirectory = dirname($config['path'] . $targetFilename); if (!is_dir($targetDirectory)) { mkdir($targetDirectory, 0755, true); } if (rename($sourcePath, $config['path'] . $targetFilename)) { raijuMoveMediaMeta($type . '-trash', $filename, $type, $targetFilename); if ($type === 'image') raijuMediaOriginalRelocate($filename, true, $targetFilename, false); jsonResponse(true, [ 'type' => $type, 'name' => $targetFilename, 'path' => $config['publicPath'] . $targetFilename, ], 'Media restored'); } jsonResponse(false, null, 'Error restoring'); break; case 'delete-media-trash': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $type = normalizeMediaType($_POST['type'] ?? ''); $filename = $_POST['filename'] ?? ''; $config = getMediaConfig($type); if (!$config || !validateMediaFilename($filename, $type)) { jsonResponse(false, null, 'Invalid media file'); } $path = $config['trashPath'] . $filename; if (!file_exists($path)) { jsonResponse(false, null, 'File not found'); } if (unlink($path)) { raijuForgetMediaMeta($type . '-trash', $filename); if ($type === 'image') raijuMediaOriginalForget($filename, true); jsonResponse(true, null, 'Media permanently deleted'); } jsonResponse(false, null, 'Error deleting'); break; case 'empty-media-trash': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $requestedType = $_POST['type'] ?? 'all'; $types = $requestedType === 'all' ? array_keys(getMediaConfig()) : array_filter(array_map('normalizeMediaType', explode(',', $requestedType))); $deleted = 0; foreach ($types as $type) { $config = getMediaConfig($type); foreach (listMediaFiles($type, true) as $media) { $path = $config['trashPath'] . $media['name']; if (is_file($path) && unlink($path)) { raijuForgetMediaMeta($type . '-trash', $media['name']); if ($type === 'image') raijuMediaOriginalForget($media['name'], true); $deleted++; } } } jsonResponse(true, ['deleted' => $deleted], 'Media trash emptied'); break; case 'media-trash-file': $type = normalizeMediaType($_GET['type'] ?? ''); $filename = $_GET['filename'] ?? ''; $config = getMediaConfig($type); if (!$config || !validateMediaFilename($filename, $type)) { http_response_code(400); header_remove('Content-Type'); echo 'Invalid media file'; exit; } $path = $config['trashPath'] . $filename; if (!is_file($path)) { http_response_code(404); header_remove('Content-Type'); echo 'File not found'; exit; } serveLocalImage($path, $filename); break; case 'list-image-trash': $images = listImageFiles(IMAGES_TRASH_PATH, 'api.php?action=image-trash-file&filename='); foreach ($images as &$image) { $image['path'] = 'api.php?action=image-trash-file&filename=' . rawurlencode($image['name']); } unset($image); jsonResponse(true, $images); break; case 'image-trash-file': $filename = $_GET['filename'] ?? ''; if (!validateImageFilename($filename)) { http_response_code(400); header_remove('Content-Type'); echo 'Invalid filename'; exit; } $path = IMAGES_TRASH_PATH . $filename; if (!is_file($path)) { http_response_code(404); header_remove('Content-Type'); echo 'File not found'; exit; } serveLocalImage($path, $filename); break; case 'upload-image': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) { $errorMsg = 'Upload error'; if (isset($_FILES['image']['error'])) { switch ($_FILES['image']['error']) { case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: $errorMsg = sprintf('File too large: the server accepts uploads up to %s MB (upload_max_filesize / post_max_size)', raijuMegabytes(raijuUploadServerLimit())); break; case UPLOAD_ERR_NO_FILE: $errorMsg = 'No file selected'; break; } } elseif (empty($_FILES) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > raijuUploadServerLimit()) { $errorMsg = sprintf('File too large: the server accepts uploads up to %s MB (upload_max_filesize / post_max_size)', raijuMegabytes(raijuUploadServerLimit())); } jsonResponse(false, null, $errorMsg); } $file = $_FILES['image']; $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $file['tmp_name']); unset($finfo); if (!in_array($mimeType, $allowedMimeTypes)) { jsonResponse(false, null, 'Only JPG, PNG, WebP and SVG allowed'); } if ($file['size'] > raijuMediaImageMaxBytes()) { jsonResponse(false, null, sprintf('Maximum file size: %s MB', raijuMegabytes(raijuMediaImageMaxBytes()))); } $explicitFilename = $_POST['filename'] ?? ''; $replaceMode = ($_POST['replace'] ?? '0') === '1'; if (!empty($explicitFilename)) { if (!validateMediaFilename($explicitFilename, 'image')) { jsonResponse(false, null, 'Invalid file extension'); } $extension = strtolower(pathinfo($explicitFilename, PATHINFO_EXTENSION)); $filename = $explicitFilename; if ($replaceMode && file_exists(IMAGES_PATH . $filename)) { if (!is_dir(IMAGES_TRASH_PATH)) { mkdir(IMAGES_TRASH_PATH, 0755, true); } $folder = trim(str_replace('\\', '/', dirname($filename)), '.'); $backupName = ($folder !== '' ? $folder . '/' : '') . pathinfo($filename, PATHINFO_FILENAME) . '_' . date('Y-m-d_His') . '.' . $extension; $backupDirectory = dirname(IMAGES_TRASH_PATH . $backupName); if (!is_dir($backupDirectory)) { mkdir($backupDirectory, 0755, true); } rename(IMAGES_PATH . $filename, IMAGES_TRASH_PATH . $backupName); } elseif (!$replaceMode && file_exists(IMAGES_PATH . $filename)) { $filename = uniqueMediaRelativePath(IMAGES_PATH, $filename); } } else { $originalName = pathinfo($file['name'], PATHINFO_FILENAME); $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $safeName = preg_replace('/[^a-z0-9\-_]/i', '-', $originalName); $safeName = preg_replace('/-+/', '-', $safeName); $safeName = trim($safeName, '-'); if (empty($safeName)) { $safeName = 'image-' . time(); } $filename = $safeName . '.' . $extension; $counter = 1; while (file_exists(IMAGES_PATH . $filename)) { $filename = $safeName . '-' . $counter . '.' . $extension; $counter++; } } // Final gate: never store a filename whose extension is not an allowed // image type. finfo checks the MIME type, but a polyglot file can carry // valid image magic bytes while keeping a .php name, so the extension // must be validated independently before the file lands in a web-served // directory (assets/images/ has no PHP-execution block). if (!validateMediaFilename($filename, 'image')) { jsonResponse(false, null, 'Invalid file extension'); } $targetDirectory = dirname(IMAGES_PATH . $filename); if (!is_dir($targetDirectory)) { mkdir($targetDirectory, 0755, true); } if (move_uploaded_file($file['tmp_name'], IMAGES_PATH . $filename)) { if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) === 'svg' && !raijuSanitizeSvgFile(IMAGES_PATH . $filename)) { @unlink(IMAGES_PATH . $filename); jsonResponse(false, null, 'SVG could not be processed safely'); } $detectedAiLabel = raijuDetectAiLabel(IMAGES_PATH . $filename); if ($detectedAiLabel !== '') { raijuSetMediaAiLabel('image', $filename, $detectedAiLabel); } jsonResponse(true, [ 'name' => $filename, 'path' => '../assets/images/' . $filename, 'aiLabel' => raijuMediaAiLabelFor('image', $filename), ], $replaceMode ? 'Image replaced' : 'Image uploaded'); } else { jsonResponse(false, null, 'Error saving'); } break; case 'delete-image': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $filename = $_POST['filename'] ?? ''; if (!validateImageFilename($filename)) { jsonResponse(false, null, 'Invalid filename'); } $sourcePath = IMAGES_PATH . $filename; if (!file_exists($sourcePath)) { jsonResponse(false, null, 'File not found'); } if (!is_dir(IMAGES_TRASH_PATH)) { mkdir(IMAGES_TRASH_PATH, 0755, true); } $targetFilename = $filename; $counter = 1; while (file_exists(IMAGES_TRASH_PATH . $targetFilename)) { $name = pathinfo($filename, PATHINFO_FILENAME); $ext = pathinfo($filename, PATHINFO_EXTENSION); $targetFilename = $name . '-' . $counter . '.' . $ext; $counter++; } if (rename($sourcePath, IMAGES_TRASH_PATH . $targetFilename)) { jsonResponse(true, null, 'Image moved to trash'); } else { jsonResponse(false, null, 'Error moving'); } break; case 'restore-image': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $filename = $_POST['filename'] ?? ''; if (!validateImageFilename($filename)) { jsonResponse(false, null, 'Invalid filename'); } $sourcePath = IMAGES_TRASH_PATH . $filename; if (!file_exists($sourcePath)) { jsonResponse(false, null, 'File not found'); } if (!is_dir(IMAGES_PATH)) { mkdir(IMAGES_PATH, 0755, true); } $targetFilename = $filename; $counter = 1; while (file_exists(IMAGES_PATH . $targetFilename)) { $name = pathinfo($filename, PATHINFO_FILENAME); $ext = pathinfo($filename, PATHINFO_EXTENSION); $targetFilename = $name . '-' . $counter . '.' . $ext; $counter++; } if (rename($sourcePath, IMAGES_PATH . $targetFilename)) { jsonResponse(true, [ 'name' => $targetFilename, 'path' => '../assets/images/' . $targetFilename ], 'Image restored'); } jsonResponse(false, null, 'Error restoring'); break; case 'delete-image-trash': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $filename = $_POST['filename'] ?? ''; if (!validateImageFilename($filename)) { jsonResponse(false, null, 'Invalid filename'); } $path = IMAGES_TRASH_PATH . $filename; if (!file_exists($path)) { jsonResponse(false, null, 'File not found'); } if (unlink($path)) { jsonResponse(true, null, 'Image permanently deleted'); } jsonResponse(false, null, 'Error deleting'); break; case 'empty-image-trash': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $deleted = 0; foreach (listImageFiles(IMAGES_TRASH_PATH, '../assets/images-trash/') as $image) { $path = IMAGES_TRASH_PATH . $image['name']; if (is_file($path) && unlink($path)) { $deleted++; } } jsonResponse(true, ['deleted' => $deleted], 'Image trash emptied'); break; // ============================================================ // AUDIO MANAGEMENT // ============================================================ case 'list-audio': if (!is_dir(AUDIO_PATH)) { jsonResponse(true, []); } $audioFiles = []; $allowedExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac']; $files = scandir(AUDIO_PATH); foreach ($files as $file) { if ($file === '.' || $file === '..') continue; $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); if (in_array($ext, $allowedExtensions)) { $sizeBytes = filesize(AUDIO_PATH . $file); $modified = filemtime(AUDIO_PATH . $file); if ($sizeBytes >= 1048576) { $sizeFormatted = round($sizeBytes / 1048576, 1) . ' MB'; } elseif ($sizeBytes >= 1024) { $sizeFormatted = round($sizeBytes / 1024, 0) . ' KB'; } else { $sizeFormatted = $sizeBytes . ' B'; } $audioFiles[] = [ 'name' => $file, 'path' => '../assets/audio/' . $file, 'sizeBytes' => $sizeBytes, 'size' => $sizeFormatted, 'modified' => $modified, 'dateFormatted' => date('d.m.Y H:i', $modified) ]; } } usort($audioFiles, function($a, $b) { return strcasecmp($a['name'], $b['name']); }); jsonResponse(true, $audioFiles); break; case 'upload-audio': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) { $errorMsg = 'Upload error'; if (isset($_FILES['audio']['error'])) { switch ($_FILES['audio']['error']) { case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: $errorMsg = 'File too large'; break; case UPLOAD_ERR_NO_FILE: $errorMsg = 'No file selected'; break; } } jsonResponse(false, null, $errorMsg); } $file = $_FILES['audio']; $allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/x-m4a', 'audio/aac', 'audio/flac']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $file['tmp_name']); unset($finfo); if (!in_array($mimeType, $allowedMimeTypes)) { jsonResponse(false, null, 'Only MP3, WAV, OGG, M4A, AAC and FLAC allowed'); } if ($file['size'] > 50 * 1024 * 1024) { jsonResponse(false, null, 'Maximum file size: 50 MB'); } $originalName = pathinfo($file['name'], PATHINFO_FILENAME); $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $safeName = preg_replace('/[^a-z0-9\-_]/i', '-', $originalName); $safeName = preg_replace('/-+/', '-', $safeName); $safeName = trim($safeName, '-'); if (empty($safeName)) { $safeName = 'audio-' . time(); } $filename = $safeName . '.' . $extension; $counter = 1; while (file_exists(AUDIO_PATH . $filename)) { $filename = $safeName . '-' . $counter . '.' . $extension; $counter++; } // Validate the final extension independently of the MIME type: a polyglot // can pass the finfo check while keeping an executable .php name. if (!validateMediaFilename($filename, 'audio')) { jsonResponse(false, null, 'Invalid file extension'); } if (!is_dir(AUDIO_PATH)) { mkdir(AUDIO_PATH, 0755, true); } if (move_uploaded_file($file['tmp_name'], AUDIO_PATH . $filename)) { jsonResponse(true, [ 'name' => $filename, 'path' => '../assets/audio/' . $filename ], 'Audio file uploaded'); } else { jsonResponse(false, null, 'Error saving'); } break; case 'delete-audio': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $filename = $_POST['filename'] ?? ''; if (empty($filename) || strpos($filename, '/') !== false || strpos($filename, '\\') !== false || strpos($filename, '..') !== false) { jsonResponse(false, null, 'Invalid filename'); } $sourcePath = AUDIO_PATH . $filename; if (!file_exists($sourcePath)) { jsonResponse(false, null, 'File not found'); } if (!is_dir(AUDIO_TRASH_PATH)) { mkdir(AUDIO_TRASH_PATH, 0755, true); } $targetFilename = $filename; $counter = 1; while (file_exists(AUDIO_TRASH_PATH . $targetFilename)) { $name = pathinfo($filename, PATHINFO_FILENAME); $ext = pathinfo($filename, PATHINFO_EXTENSION); $targetFilename = $name . '-' . $counter . '.' . $ext; $counter++; } if (rename($sourcePath, AUDIO_TRASH_PATH . $targetFilename)) { jsonResponse(true, null, 'Audio file moved to trash'); } else { jsonResponse(false, null, 'Error moving'); } break; // ============================================================ // NEWS / BLOG MANAGEMENT // ============================================================ }