Files
aj-portfolio/public/admin/project_media.php
2025-12-23 13:18:58 +02:00

333 lines
11 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
const MEDIA_MAX_BYTES = 8 * 1024 * 1024; // 8MB
const MEDIA_MAX_SIDE = 4000; // max width/height
function respond(array $payload, int $status = 200): void
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
exit;
}
function require_csrf(): string
{
$token = (string)($_POST['csrf'] ?? $_GET['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!csrf_check($token)) {
respond(['ok' => false, 'error' => 'Invalid CSRF token.'], 400);
}
return $token;
}
function ensure_gd(): void
{
if (!extension_loaded('gd')) {
respond(['ok' => false, 'error' => 'GD extension is required on the server.'], 500);
}
}
function load_project(int $id): array
{
$st = pdo()->prepare("SELECT id, slug, title FROM projects WHERE id = ? LIMIT 1");
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
respond(['ok' => false, 'error' => 'Project not found.'], 404);
}
return $row;
}
function ensure_media_dir(string $dir): void
{
if (!is_dir($dir)) {
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
respond(['ok' => false, 'error' => 'Cannot create media directory.'], 500);
}
}
if (!is_writable($dir)) {
respond(['ok' => false, 'error' => 'Media directory is not writable.'], 500);
}
}
function list_media(string $dir, string $folder): array
{
$files = [];
if (!is_dir($dir)) return $files;
foreach (glob($dir . DIRECTORY_SEPARATOR . '*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [] as $path) {
if (!is_file($path)) continue;
$name = basename($path);
$stat = @stat($path) ?: [];
$size = (int)($stat['size'] ?? 0);
$mtime = (int)($stat['mtime'] ?? time());
$dim = @getimagesize($path) ?: [];
$files[] = [
'name' => $name,
'url' => project_media_url($folder, $name),
'size' => $size,
'width' => (int)($dim[0] ?? 0),
'height' => (int)($dim[1] ?? 0),
'modified'=> $mtime,
];
}
usort($files, fn($a, $b) => ($b['modified'] ?? 0) <=> ($a['modified'] ?? 0));
return $files;
}
function normalize_orientation($img, array $info, string $binary)
{
if (!function_exists('exif_read_data')) return $img;
$mime = strtolower((string)($info['mime'] ?? ''));
if ($mime !== 'image/jpeg') return $img;
$orientation = 0;
try {
$exif = @exif_read_data('data://image/jpeg;base64,' . base64_encode($binary), 'IFD0');
if (!empty($exif['Orientation'])) $orientation = (int)$exif['Orientation'];
} catch (Throwable $e) {
return $img;
}
if ($orientation < 2 || $orientation > 8) return $img;
$canFlip = function_exists('imageflip');
switch ($orientation) {
case 2: if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
case 3: $img = imagerotate($img, 180, 0); break;
case 4: if ($canFlip) imageflip($img, IMG_FLIP_VERTICAL); break;
case 5: $img = imagerotate($img, 90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
case 6: $img = imagerotate($img, -90, 0); break;
case 7: $img = imagerotate($img, -90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
case 8: $img = imagerotate($img, 90, 0); break;
}
return $img;
}
function save_image(string $binary, string $dir, string $folder): array
{
ensure_gd();
if (strlen($binary) > MEDIA_MAX_BYTES) {
respond(['ok' => false, 'error' => 'Image exceeds 8MB limit.'], 400);
}
$info = @getimagesizefromstring($binary);
if (!$info || empty($info['mime'])) {
respond(['ok' => false, 'error' => 'Unsupported or invalid image.'], 400);
}
$mime = strtolower((string)$info['mime']);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
respond(['ok' => false, 'error' => 'Only JPEG, PNG or WebP images are allowed.'], 400);
}
$img = @imagecreatefromstring($binary);
if (!$img) {
respond(['ok' => false, 'error' => 'Unable to read the image data.'], 400);
}
$img = normalize_orientation($img, $info, $binary);
$width = imagesx($img);
$height = imagesy($img);
$maxSide = MEDIA_MAX_SIDE;
if ($width > $maxSide || $height > $maxSide) {
$scale = $maxSide / max($width, $height);
$newW = (int)max(1, floor($width * $scale));
$newH = (int)max(1, floor($height * $scale));
$dst = imagecreatetruecolor($newW, $newH);
imagealphablending($dst, true);
imagesavealpha($dst, true);
imagecopyresampled($dst, $img, 0, 0, 0, 0, $newW, $newH, $width, $height);
imagedestroy($img);
$img = $dst;
$width = $newW;
$height = $newH;
}
$ext = 'jpg';
if ($mime === 'image/png') $ext = 'png';
if ($mime === 'image/webp' && function_exists('imagewebp')) $ext = 'webp';
$filename = 'img-' . date('Ymd-His') . '-' . bin2hex(random_bytes(3)) . '.' . $ext;
$path = $dir . DIRECTORY_SEPARATOR . $filename;
switch ($ext) {
case 'png':
imagepng($img, $path, 6);
break;
case 'webp':
imagewebp($img, $path, 85);
break;
default:
imagejpeg($img, $path, 85);
break;
}
imagedestroy($img);
$stat = @stat($path) ?: [];
$dim2 = @getimagesize($path) ?: [];
return [
'name' => $filename,
'url' => project_media_url($folder, $filename),
'size' => (int)($stat['size'] ?? 0),
'width' => (int)($dim2[0] ?? $width),
'height' => (int)($dim2[1] ?? $height),
'modified' => (int)($stat['mtime'] ?? time()),
];
}
function read_uploaded_file(array $files, int $idx): string
{
$tmp = (string)($files['tmp_name'][$idx] ?? '');
$size = (int)($files['size'][$idx] ?? 0);
$err = (int)($files['error'][$idx] ?? UPLOAD_ERR_NO_FILE);
if ($err !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload failed for one file.');
}
if (!is_uploaded_file($tmp)) {
throw new RuntimeException('Suspicious upload rejected.');
}
if ($size > MEDIA_MAX_BYTES) {
throw new RuntimeException('File is larger than 8MB.');
}
$data = file_get_contents($tmp);
if ($data === false) {
throw new RuntimeException('Unable to read upload.');
}
if (strlen($data) > MEDIA_MAX_BYTES) {
throw new RuntimeException('File is larger than 8MB.');
}
return $data;
}
function fetch_remote_image(string $url): string
{
$url = trim($url);
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException('Please provide a valid image URL.');
}
if (!preg_match('#^https?://#i', $url)) {
throw new RuntimeException('Only http/https URLs are allowed.');
}
$data = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
CURLOPT_USERAGENT => 'PortfolioMediaFetcher/1.0',
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_BUFFERSIZE => 102400,
]);
$data = (string)curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch) || $code >= 400 || $code === 0) {
curl_close($ch);
throw new RuntimeException('Failed to download image (HTTP ' . $code . ').');
}
curl_close($ch);
} else {
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 10,
'header' => "User-Agent: PortfolioMediaFetcher/1.0\r\n",
],
]);
$fh = @fopen($url, 'rb', false, $ctx);
if (!$fh) throw new RuntimeException('Could not open the URL.');
while (!feof($fh) && strlen($data) <= MEDIA_MAX_BYTES) {
$chunk = fread($fh, 8192);
if ($chunk === false) break;
$data .= $chunk;
if (strlen($data) > MEDIA_MAX_BYTES) {
fclose($fh);
throw new RuntimeException('Remote image is larger than 8MB.');
}
}
fclose($fh);
}
if ($data === '' || strlen($data) === 0) {
throw new RuntimeException('Image download returned empty data.');
}
if (strlen($data) > MEDIA_MAX_BYTES) {
throw new RuntimeException('Remote image is larger than 8MB.');
}
return $data;
}
// ----- Main routing -----
$csrfToken = require_csrf();
$projectId = (int)($_GET['project_id'] ?? $_POST['project_id'] ?? 0);
if ($projectId <= 0) respond(['ok' => false, 'error' => 'Missing project id.'], 400);
$project = load_project($projectId);
$folder = project_media_folder($project);
$dir = project_media_dir($project);
ensure_media_dir($dir);
$action = strtolower((string)($_REQUEST['action'] ?? ($_SERVER['REQUEST_METHOD'] === 'GET' ? 'list' : '')));
try {
if ($action === 'list') {
respond(['ok' => true, 'files' => list_media($dir, $folder)]);
}
if ($action === 'upload') {
$files = $_FILES['files'] ?? null;
if (!$files || !isset($files['tmp_name']) || !is_array($files['tmp_name'])) {
respond(['ok' => false, 'error' => 'No files received.'], 400);
}
$saved = [];
$total = count($files['tmp_name']);
for ($i = 0; $i < $total; $i++) {
$data = read_uploaded_file($files, $i);
$saved[] = save_image($data, $dir, $folder);
if (count($saved) >= 10) break; // prevent huge multi-upload bursts
}
respond(['ok' => true, 'saved' => count($saved), 'files' => $saved]);
}
if ($action === 'fetch_url') {
$url = (string)($_POST['image_url'] ?? '');
$data = fetch_remote_image($url);
$file = save_image($data, $dir, $folder);
respond(['ok' => true, 'saved' => 1, 'files' => [$file]]);
}
if ($action === 'delete') {
$name = (string)($_POST['name'] ?? '');
if ($name === '' || !preg_match('/^[a-zA-Z0-9._-]+$/', $name)) {
respond(['ok' => false, 'error' => 'Bad filename.'], 400);
}
$path = realpath($dir . DIRECTORY_SEPARATOR . $name);
$dirReal = realpath($dir);
if (!$path || !$dirReal || strpos($path, $dirReal) !== 0 || !is_file($path)) {
respond(['ok' => false, 'error' => 'File not found.'], 404);
}
@unlink($path);
respond(['ok' => true]);
}
respond(['ok' => false, 'error' => 'Unknown action.'], 400);
} catch (Throwable $e) {
respond(['ok' => false, 'error' => $e->getMessage()], 400);
}