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