Initial commit: Site Builder with PHP API backend
Visual drag-and-drop website builder using GrapesJS with: - Multi-page editor with live preview - File-based asset storage via PHP API (no localStorage base64) - Template library, Docker support, and Playwright test suite Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
193
api/image-resize.php
Normal file
193
api/image-resize.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/**
|
||||
* Image Resize/Crop API
|
||||
*
|
||||
* Usage:
|
||||
* POST /api/image-resize.php
|
||||
* Parameters:
|
||||
* - image: uploaded file (multipart) OR url (string)
|
||||
* - width: target width (int)
|
||||
* - height: target height (int)
|
||||
* - mode: 'resize' | 'crop' | 'fit' (default: resize)
|
||||
* - quality: 1-100 (default: 85)
|
||||
* - format: 'jpg' | 'png' | 'webp' (default: auto)
|
||||
*
|
||||
* Returns: JSON { success: true, url: "path/to/resized.jpg", width: N, height: N }
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Configuration
|
||||
$uploadDir = __DIR__ . '/../uploads/';
|
||||
$outputDir = __DIR__ . '/../uploads/resized/';
|
||||
$maxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// Create directories
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
if (!is_dir($outputDir)) mkdir($outputDir, 0755, true);
|
||||
|
||||
// Get parameters
|
||||
$width = intval($_POST['width'] ?? 0);
|
||||
$height = intval($_POST['height'] ?? 0);
|
||||
$mode = $_POST['mode'] ?? 'resize';
|
||||
$quality = intval($_POST['quality'] ?? 85);
|
||||
$format = $_POST['format'] ?? 'auto';
|
||||
|
||||
if ($width <= 0 && $height <= 0) {
|
||||
echo json_encode(['error' => 'Width or height required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$quality = max(1, min(100, $quality));
|
||||
|
||||
// Get source image
|
||||
$sourcePath = null;
|
||||
$cleanup = false;
|
||||
|
||||
if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
|
||||
if ($_FILES['image']['size'] > $maxFileSize) {
|
||||
echo json_encode(['error' => 'File too large (max 10MB)']);
|
||||
exit;
|
||||
}
|
||||
$sourcePath = $_FILES['image']['tmp_name'];
|
||||
} elseif (!empty($_POST['url'])) {
|
||||
$url = filter_var($_POST['url'], FILTER_VALIDATE_URL);
|
||||
if (!$url) {
|
||||
echo json_encode(['error' => 'Invalid URL']);
|
||||
exit;
|
||||
}
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'img_');
|
||||
$content = @file_get_contents($url);
|
||||
if ($content === false) {
|
||||
echo json_encode(['error' => 'Could not download image']);
|
||||
exit;
|
||||
}
|
||||
file_put_contents($tempFile, $content);
|
||||
$sourcePath = $tempFile;
|
||||
$cleanup = true;
|
||||
} else {
|
||||
echo json_encode(['error' => 'No image provided']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Detect image type
|
||||
$info = @getimagesize($sourcePath);
|
||||
if (!$info) {
|
||||
if ($cleanup) unlink($sourcePath);
|
||||
echo json_encode(['error' => 'Invalid image file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$srcWidth = $info[0];
|
||||
$srcHeight = $info[1];
|
||||
$mime = $info['mime'];
|
||||
|
||||
// Create source image resource
|
||||
switch ($mime) {
|
||||
case 'image/jpeg': $srcImg = imagecreatefromjpeg($sourcePath); break;
|
||||
case 'image/png': $srcImg = imagecreatefrompng($sourcePath); break;
|
||||
case 'image/gif': $srcImg = imagecreatefromgif($sourcePath); break;
|
||||
case 'image/webp': $srcImg = imagecreatefromwebp($sourcePath); break;
|
||||
default:
|
||||
if ($cleanup) unlink($sourcePath);
|
||||
echo json_encode(['error' => 'Unsupported image type: ' . $mime]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($cleanup) unlink($sourcePath);
|
||||
|
||||
// Calculate dimensions
|
||||
$dstWidth = $width;
|
||||
$dstHeight = $height;
|
||||
$srcX = 0;
|
||||
$srcY = 0;
|
||||
$cropWidth = $srcWidth;
|
||||
$cropHeight = $srcHeight;
|
||||
|
||||
if ($mode === 'resize') {
|
||||
if ($dstWidth <= 0) $dstWidth = intval($srcWidth * ($dstHeight / $srcHeight));
|
||||
if ($dstHeight <= 0) $dstHeight = intval($srcHeight * ($dstWidth / $srcWidth));
|
||||
} elseif ($mode === 'fit') {
|
||||
if ($dstWidth <= 0) $dstWidth = $srcWidth;
|
||||
if ($dstHeight <= 0) $dstHeight = $srcHeight;
|
||||
$ratio = min($dstWidth / $srcWidth, $dstHeight / $srcHeight);
|
||||
$dstWidth = intval($srcWidth * $ratio);
|
||||
$dstHeight = intval($srcHeight * $ratio);
|
||||
} elseif ($mode === 'crop') {
|
||||
if ($dstWidth <= 0) $dstWidth = $dstHeight;
|
||||
if ($dstHeight <= 0) $dstHeight = $dstWidth;
|
||||
$ratio = max($dstWidth / $srcWidth, $dstHeight / $srcHeight);
|
||||
$cropWidth = intval($dstWidth / $ratio);
|
||||
$cropHeight = intval($dstHeight / $ratio);
|
||||
$srcX = intval(($srcWidth - $cropWidth) / 2);
|
||||
$srcY = intval(($srcHeight - $cropHeight) / 2);
|
||||
}
|
||||
|
||||
// Create destination image
|
||||
$dstImg = imagecreatetruecolor($dstWidth, $dstHeight);
|
||||
|
||||
// Preserve transparency for PNG
|
||||
if ($mime === 'image/png' || $format === 'png') {
|
||||
imagealphablending($dstImg, false);
|
||||
imagesavealpha($dstImg, true);
|
||||
}
|
||||
|
||||
// Resample
|
||||
imagecopyresampled($dstImg, $srcImg, 0, 0, $srcX, $srcY, $dstWidth, $dstHeight, $cropWidth, $cropHeight);
|
||||
|
||||
// Determine output format
|
||||
if ($format === 'auto') {
|
||||
$format = match($mime) {
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => 'jpg'
|
||||
};
|
||||
}
|
||||
|
||||
// Generate output filename
|
||||
$filename = 'img_' . uniqid() . '_' . $dstWidth . 'x' . $dstHeight . '.' . $format;
|
||||
$outputPath = $outputDir . $filename;
|
||||
|
||||
// Save
|
||||
switch ($format) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
imagejpeg($dstImg, $outputPath, $quality);
|
||||
break;
|
||||
case 'png':
|
||||
imagepng($dstImg, $outputPath, intval(9 - ($quality / 100 * 9)));
|
||||
break;
|
||||
case 'webp':
|
||||
imagewebp($dstImg, $outputPath, $quality);
|
||||
break;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
imagedestroy($srcImg);
|
||||
imagedestroy($dstImg);
|
||||
|
||||
// Return result
|
||||
$relPath = 'uploads/resized/' . $filename;
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'url' => $relPath,
|
||||
'width' => $dstWidth,
|
||||
'height' => $dstHeight,
|
||||
'format' => $format,
|
||||
'size' => filesize($outputPath)
|
||||
]);
|
||||
363
api/index.php
Normal file
363
api/index.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
/**
|
||||
* Site Builder API Router
|
||||
*
|
||||
* Handles asset uploads/management and project storage.
|
||||
* All assets are stored as files on disk (no base64, no localStorage).
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/health - Health check
|
||||
* POST /api/assets/upload - Upload file (multipart/form-data)
|
||||
* GET /api/assets - List all stored assets
|
||||
* DELETE /api/assets/<filename> - Delete an asset
|
||||
* POST /api/projects/save - Save project data (JSON body)
|
||||
* GET /api/projects/list - List all saved projects
|
||||
* GET /api/projects/<id> - Load a specific project
|
||||
* DELETE /api/projects/<id> - Delete a project
|
||||
*/
|
||||
|
||||
// --- Configuration ---
|
||||
define('STORAGE_DIR', __DIR__ . '/../storage');
|
||||
define('ASSETS_DIR', STORAGE_DIR . '/assets');
|
||||
define('PROJECTS_DIR', STORAGE_DIR . '/projects');
|
||||
define('TMP_DIR', STORAGE_DIR . '/tmp');
|
||||
define('MAX_UPLOAD_SIZE', 500 * 1024 * 1024); // 500MB
|
||||
|
||||
// --- CORS & Headers ---
|
||||
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');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Ensure storage directories ---
|
||||
foreach ([STORAGE_DIR, ASSETS_DIR, PROJECTS_DIR, TMP_DIR] as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routing ---
|
||||
// Determine the API path from the request URI
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||
|
||||
// Strip trailing slash for consistency
|
||||
$path = rtrim($path, '/');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Route the request
|
||||
if ($path === '/api/health') {
|
||||
handleHealth();
|
||||
} elseif ($path === '/api/assets/upload' && $method === 'POST') {
|
||||
handleUpload();
|
||||
} elseif ($path === '/api/assets' && $method === 'GET') {
|
||||
handleListAssets();
|
||||
} elseif (preg_match('#^/api/assets/(.+)$#', $path, $m) && $method === 'DELETE') {
|
||||
handleDeleteAsset(urldecode($m[1]));
|
||||
} elseif ($path === '/api/projects/save' && ($method === 'POST' || $method === 'PUT')) {
|
||||
handleSaveProject();
|
||||
} elseif ($path === '/api/projects/list' && $method === 'GET') {
|
||||
handleListProjects();
|
||||
} elseif (preg_match('#^/api/projects/(.+)$#', $path, $m) && $method === 'GET') {
|
||||
handleGetProject(urldecode($m[1]));
|
||||
} elseif (preg_match('#^/api/projects/(.+)$#', $path, $m) && $method === 'DELETE') {
|
||||
handleDeleteProject(urldecode($m[1]));
|
||||
} else {
|
||||
sendError('Not Found', 404);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Helper Functions
|
||||
// ===========================================
|
||||
|
||||
function sendJson($data, $status = 200) {
|
||||
http_response_code($status);
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function sendError($message, $status = 400) {
|
||||
sendJson(['success' => false, 'error' => $message], $status);
|
||||
}
|
||||
|
||||
function generateUniqueFilename($originalName) {
|
||||
$timestamp = round(microtime(true) * 1000);
|
||||
$rand = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 6);
|
||||
// Sanitize original name
|
||||
$safeName = preg_replace('/[^\w.\-]/', '_', $originalName);
|
||||
$ext = pathinfo($safeName, PATHINFO_EXTENSION);
|
||||
$name = pathinfo($safeName, PATHINFO_FILENAME);
|
||||
if (empty($ext)) {
|
||||
$ext = 'bin';
|
||||
}
|
||||
return "{$timestamp}_{$rand}_{$name}.{$ext}";
|
||||
}
|
||||
|
||||
function getAssetType($filename) {
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
$imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp', 'tiff'];
|
||||
$videoExts = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'];
|
||||
$cssExts = ['css'];
|
||||
$jsExts = ['js'];
|
||||
|
||||
if (in_array($ext, $imageExts)) return 'image';
|
||||
if (in_array($ext, $videoExts)) return 'video';
|
||||
if (in_array($ext, $cssExts)) return 'css';
|
||||
if (in_array($ext, $jsExts)) return 'js';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function getAssetInfo($filename) {
|
||||
$filepath = ASSETS_DIR . '/' . $filename;
|
||||
if (!is_file($filepath)) return null;
|
||||
|
||||
$stat = stat($filepath);
|
||||
// Extract original name from naming scheme: timestamp_random_originalname
|
||||
$originalName = $filename;
|
||||
$parts = explode('_', $filename, 3);
|
||||
if (count($parts) >= 3) {
|
||||
$originalName = $parts[2];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $filename,
|
||||
'name' => $originalName,
|
||||
'filename' => $filename,
|
||||
'url' => '/storage/assets/' . rawurlencode($filename),
|
||||
'type' => getAssetType($filename),
|
||||
'size' => $stat['size'],
|
||||
'added' => intval($stat['mtime'] * 1000)
|
||||
];
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Endpoint Handlers
|
||||
// ===========================================
|
||||
|
||||
function handleHealth() {
|
||||
sendJson(['status' => 'ok', 'server' => 'site-builder']);
|
||||
}
|
||||
|
||||
function handleUpload() {
|
||||
$contentLength = intval($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||
if ($contentLength > MAX_UPLOAD_SIZE) {
|
||||
sendError('File too large. Maximum size is 500MB.', 413);
|
||||
}
|
||||
|
||||
if (empty($_FILES)) {
|
||||
sendError('No files uploaded. Expected multipart/form-data with a "file" field.', 400);
|
||||
}
|
||||
|
||||
$uploaded = [];
|
||||
|
||||
// Handle single file field named 'file' or multiple files
|
||||
$files = [];
|
||||
if (isset($_FILES['file'])) {
|
||||
// Could be single or array upload
|
||||
if (is_array($_FILES['file']['name'])) {
|
||||
for ($i = 0; $i < count($_FILES['file']['name']); $i++) {
|
||||
$files[] = [
|
||||
'name' => $_FILES['file']['name'][$i],
|
||||
'tmp_name' => $_FILES['file']['tmp_name'][$i],
|
||||
'error' => $_FILES['file']['error'][$i],
|
||||
'size' => $_FILES['file']['size'][$i],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$files[] = $_FILES['file'];
|
||||
}
|
||||
} else {
|
||||
// Accept any file field name
|
||||
foreach ($_FILES as $fileField) {
|
||||
if (is_array($fileField['name'])) {
|
||||
for ($i = 0; $i < count($fileField['name']); $i++) {
|
||||
$files[] = [
|
||||
'name' => $fileField['name'][$i],
|
||||
'tmp_name' => $fileField['tmp_name'][$i],
|
||||
'error' => $fileField['error'][$i],
|
||||
'size' => $fileField['size'][$i],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$files[] = $fileField;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
continue;
|
||||
}
|
||||
if (empty($file['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uniqueName = generateUniqueFilename($file['name']);
|
||||
$destPath = ASSETS_DIR . '/' . $uniqueName;
|
||||
|
||||
if (move_uploaded_file($file['tmp_name'], $destPath)) {
|
||||
$info = getAssetInfo($uniqueName);
|
||||
if ($info) {
|
||||
$uploaded[] = $info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($uploaded)) {
|
||||
sendJson(['success' => true, 'assets' => $uploaded]);
|
||||
} else {
|
||||
sendError('No files were uploaded', 400);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListAssets() {
|
||||
$assets = [];
|
||||
if (is_dir(ASSETS_DIR)) {
|
||||
$entries = scandir(ASSETS_DIR);
|
||||
sort($entries);
|
||||
foreach ($entries as $filename) {
|
||||
if ($filename === '.' || $filename === '..') continue;
|
||||
$info = getAssetInfo($filename);
|
||||
if ($info) {
|
||||
$assets[] = $info;
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJson(['success' => true, 'assets' => $assets]);
|
||||
}
|
||||
|
||||
function handleDeleteAsset($filename) {
|
||||
if (empty($filename)) {
|
||||
sendError('No filename specified', 400);
|
||||
}
|
||||
|
||||
// Security: prevent directory traversal
|
||||
$safeFilename = basename($filename);
|
||||
$filepath = ASSETS_DIR . '/' . $safeFilename;
|
||||
|
||||
if (!is_file($filepath)) {
|
||||
sendError('Asset not found', 404);
|
||||
}
|
||||
|
||||
if (unlink($filepath)) {
|
||||
sendJson(['success' => true, 'deleted' => $safeFilename]);
|
||||
} else {
|
||||
sendError('Failed to delete asset', 500);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveProject() {
|
||||
$body = file_get_contents('php://input');
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if ($data === null) {
|
||||
sendError('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
$projectId = $data['id'] ?? null;
|
||||
if (empty($projectId)) {
|
||||
$projectId = 'project_' . time() . '_' . rand(1000, 9999);
|
||||
$data['id'] = $projectId;
|
||||
}
|
||||
|
||||
// Sanitize project ID for filesystem
|
||||
$safeId = preg_replace('/[^\w.\-]/', '_', $projectId);
|
||||
$filepath = PROJECTS_DIR . '/' . $safeId . '.json';
|
||||
|
||||
// Add timestamps
|
||||
if (empty($data['created'])) {
|
||||
$data['created'] = round(microtime(true) * 1000);
|
||||
}
|
||||
$data['modified'] = round(microtime(true) * 1000);
|
||||
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT);
|
||||
if (file_put_contents($filepath, $json) !== false) {
|
||||
sendJson([
|
||||
'success' => true,
|
||||
'project' => [
|
||||
'id' => $projectId,
|
||||
'name' => $data['name'] ?? 'Untitled',
|
||||
'modified' => $data['modified']
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
sendError('Failed to save project', 500);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListProjects() {
|
||||
$projects = [];
|
||||
if (is_dir(PROJECTS_DIR)) {
|
||||
$entries = scandir(PROJECTS_DIR);
|
||||
foreach ($entries as $filename) {
|
||||
if (!str_ends_with($filename, '.json')) continue;
|
||||
$filepath = PROJECTS_DIR . '/' . $filename;
|
||||
$content = @file_get_contents($filepath);
|
||||
if ($content === false) continue;
|
||||
$data = @json_decode($content, true);
|
||||
if ($data === null) continue;
|
||||
|
||||
$projects[] = [
|
||||
'id' => $data['id'] ?? pathinfo($filename, PATHINFO_FILENAME),
|
||||
'name' => $data['name'] ?? 'Untitled',
|
||||
'modified' => $data['modified'] ?? 0,
|
||||
'created' => $data['created'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modified date descending
|
||||
usort($projects, function($a, $b) {
|
||||
return ($b['modified'] ?? 0) - ($a['modified'] ?? 0);
|
||||
});
|
||||
|
||||
sendJson(['success' => true, 'projects' => $projects]);
|
||||
}
|
||||
|
||||
function handleGetProject($projectId) {
|
||||
if (empty($projectId)) {
|
||||
handleListProjects();
|
||||
return;
|
||||
}
|
||||
|
||||
$safeId = preg_replace('/[^\w.\-]/', '_', $projectId);
|
||||
$filepath = PROJECTS_DIR . '/' . $safeId . '.json';
|
||||
|
||||
if (!is_file($filepath)) {
|
||||
sendError('Project not found', 404);
|
||||
}
|
||||
|
||||
$content = file_get_contents($filepath);
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if ($data === null) {
|
||||
sendError('Failed to parse project data', 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'project' => $data]);
|
||||
}
|
||||
|
||||
function handleDeleteProject($projectId) {
|
||||
if (empty($projectId)) {
|
||||
sendError('No project ID specified', 400);
|
||||
}
|
||||
|
||||
$safeId = preg_replace('/[^\w.\-]/', '_', $projectId);
|
||||
$filepath = PROJECTS_DIR . '/' . $safeId . '.json';
|
||||
|
||||
if (!is_file($filepath)) {
|
||||
sendError('Project not found', 404);
|
||||
}
|
||||
|
||||
if (unlink($filepath)) {
|
||||
sendJson(['success' => true, 'deleted' => $projectId]);
|
||||
} else {
|
||||
sendError('Failed to delete project', 500);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user