951 lines
36 KiB
PHP
951 lines
36 KiB
PHP
<?php
|
|
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
|
|
|
|
require_once RAIJU_ADMIN_DIR . '/../includes/svg-sanitizer.php';
|
|
|
|
// Authenticated dispatcher supplies shared helpers and request context.
|
|
switch ($action) {
|
|
case 'list-images':
|
|
jsonResponse(true, listImageFiles(IMAGES_PATH, '../assets/images/'));
|
|
break;
|
|
|
|
case 'list-media':
|
|
$requestedType = $_GET['type'] ?? 'all';
|
|
$trash = ($_GET['trash'] ?? '0') === '1';
|
|
$types = $requestedType === 'all'
|
|
? array_keys(getMediaConfig())
|
|
: array_filter(array_map('normalizeMediaType', explode(',', $requestedType)));
|
|
|
|
$items = [];
|
|
foreach ($types as $type) {
|
|
$items = array_merge($items, listMediaFiles($type, $trash));
|
|
}
|
|
$folders = [];
|
|
foreach ($types as $type) {
|
|
$folders = array_merge($folders, listMediaFolders($type, $trash));
|
|
}
|
|
$folders = array_values(array_unique($folders));
|
|
natcasesort($folders);
|
|
$folders = array_values($folders);
|
|
usort($items, function($a, $b) {
|
|
return ($b['modified'] ?? 0) <=> ($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
|
|
// ============================================================
|
|
|
|
}
|