333 lines
11 KiB
PHP
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);
|
|
}
|