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

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);
}
}