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:
2026-02-28 19:25:42 +00:00
commit a71b58c2c7
58 changed files with 14464 additions and 0 deletions

193
api/image-resize.php Normal file
View 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
View 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);
}
}