Raiju/admin/api/pages.php
2026-10-25 06:05:42 +03:00

531 lines
18 KiB
PHP

<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'list-pages':
jsonResponse(true, buildPageList());
break;
case 'create-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$title = trim($_POST['title'] ?? '');
$slug = trim($_POST['slug'] ?? '');
$lang = trim($_POST['lang'] ?? '');
if (empty($title)) {
jsonResponse(false, null, 'Title is required');
}
if (!raijuPageIsValidPath($slug)) {
jsonResponse(false, null, 'Invalid path (use lowercase URL segments separated by slashes)');
}
if (!preg_match('/^[a-z]{2}$/', $lang)) {
jsonResponse(false, null, 'Invalid language');
}
$pageName = raijuPageContentKey($lang, $slug);
$filepath = CONTENT_PATH . $pageName . '.json';
if (file_exists($filepath)) {
jsonResponse(false, null, 'A page with this slug already exists');
}
$content = [
'page' => $pageName,
'path' => $slug,
'lang' => $lang,
'title' => $title,
'description' => '',
'visibility' => [
'status' => 'public',
'title' => '',
'text' => '',
],
'seo' => [
'title' => '',
'description' => '',
'answerSummary' => '',
'canonical' => '',
'robots' => 'index, follow',
'ogTitle' => '',
'ogDescription' => '',
'ogImage' => '',
'sitemap' => true,
],
'lastModified' => date('c'),
'sections' => [
[
'id' => 'section_heading',
'type' => 'text',
'title' => $title,
'titleTag' => 'h1',
'content' => '<p>Page content goes here.</p>',
],
],
];
$result = file_put_contents(
$filepath,
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
if ($result === false) {
jsonResponse(false, null, 'Error creating page');
}
jsonResponse(true, ['page' => $pageName, 'pageList' => buildPageList()], 'Page created');
break;
case 'copy-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$sourcePage = $_POST['source'] ?? '';
$targetLang = $_POST['targetLang'] ?? '';
$slug = $_POST['slug'] ?? '';
if (!validatePageName($sourcePage)) {
jsonResponse(false, null, 'Invalid source page name');
}
if (!preg_match('/^[a-z]{2}$/', $targetLang)) {
jsonResponse(false, null, 'Invalid target language');
}
if (!raijuPageIsValidPath($slug)) {
jsonResponse(false, null, 'Invalid slug');
}
$sourceFile = CONTENT_PATH . $sourcePage . '.json';
if (!file_exists($sourceFile)) {
jsonResponse(false, null, 'Source page does not exist');
}
$targetPage = raijuPageContentKey($targetLang, $slug);
$targetFile = CONTENT_PATH . $targetPage . '.json';
if (file_exists($targetFile)) {
jsonResponse(false, null, 'Target page already exists');
}
$content = json_decode(file_get_contents($sourceFile), true);
if ($content === null) {
jsonResponse(false, null, 'Error reading source page');
}
// Update metadata for the new language
$content['page'] = $targetPage;
$content['path'] = $slug;
$content['lang'] = $targetLang;
$content['lastModified'] = date('c');
$result = file_put_contents(
$targetFile,
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
if ($result === false) {
jsonResponse(false, null, 'Error creating page');
}
jsonResponse(true, ['page' => $targetPage, 'pageList' => buildPageList()], 'Page created as copy');
break;
case 'delete-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$page = $_POST['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$filepath = CONTENT_PATH . $page . '.json';
if (!file_exists($filepath)) {
jsonResponse(false, null, 'Page does not exist');
}
// Move to trash instead of deleting
if (!is_dir(PAGES_TRASH_PATH)) {
mkdir(PAGES_TRASH_PATH, 0755, true);
}
$timestamp = date('Y-m-d_His');
$trashName = $page . '_' . $timestamp . '.json';
if (!rename($filepath, PAGES_TRASH_PATH . $trashName)) {
jsonResponse(false, null, 'Error moving page to trash');
}
jsonResponse(true, ['pageList' => buildPageList()], 'Page moved to trash');
break;
case 'duplicate-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$sourcePage = $_POST['source'] ?? '';
if (!validatePageName($sourcePage)) {
jsonResponse(false, null, 'Invalid page name');
}
$sourceFile = CONTENT_PATH . $sourcePage . '.json';
if (!file_exists($sourceFile)) {
jsonResponse(false, null, 'Source page does not exist');
}
// Find a unique slug: append -copy, -copy-2, etc.
// Extract lang and slug from source
$underscorePos = strpos($sourcePage, '_');
$lang = substr($sourcePage, 0, $underscorePos);
$slug = substr($sourcePage, $underscorePos + 1);
$copySlug = $slug . '-copy';
$counter = 2;
while (file_exists(CONTENT_PATH . raijuPageContentKey($lang, $copySlug) . '.json')) {
$copySlug = $slug . '-copy-' . $counter;
$counter++;
}
$newPage = raijuPageContentKey($lang, $copySlug);
$content = json_decode(file_get_contents($sourceFile), true);
if ($content === null) {
jsonResponse(false, null, 'Error reading source page');
}
$content['page'] = $newPage;
$content['path'] = $copySlug;
if (isset($content['title'])) {
$content['title'] .= ' (Copy)';
}
$content['lastModified'] = date('c');
$result = file_put_contents(
CONTENT_PATH . $newPage . '.json',
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
if ($result === false) {
jsonResponse(false, null, 'Error duplicating page');
}
jsonResponse(true, ['page' => $newPage, 'slug' => $copySlug, 'pageList' => buildPageList()], 'Page duplicated');
break;
// ============================================================
// PAGE TRASH
// ============================================================
case 'list-trash':
if (!is_dir(PAGES_TRASH_PATH)) {
jsonResponse(true, []);
}
$trashItems = [];
$files = glob(PAGES_TRASH_PATH . '*.json');
foreach ($files as $file) {
$filename = basename($file, '.json');
// Parse: {lang}_{slug}_{date}_{time}
if (!preg_match('/^(.+)_(\d{4}-\d{2}-\d{2})_(\d{6})$/', $filename, $m) || !validatePageName($m[1])) {
continue;
}
$pageName = $m[1];
$date = $m[2];
$time = substr($m[3], 0, 2) . ':' . substr($m[3], 2, 2) . ':' . substr($m[3], 4, 2);
$data = json_decode(file_get_contents($file), true);
$trashItems[] = [
'filename' => basename($file),
'page' => $pageName,
'title' => $data['title'] ?? ucfirst(str_replace('-', ' ', substr($pageName, 3))),
'lang' => $data['lang'] ?? substr($pageName, 0, 2),
'deletedDate' => $date,
'deletedTime' => $time,
'timestamp' => filemtime($file),
];
}
usort($trashItems, function($a, $b) {
return $b['timestamp'] - $a['timestamp'];
});
jsonResponse(true, $trashItems);
break;
case 'restore-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$trashFile = $_POST['filename'] ?? '';
if (empty($trashFile) || !validateBackupName($trashFile)) {
jsonResponse(false, null, 'Invalid trash filename');
}
$trashPath = PAGES_TRASH_PATH . $trashFile;
if (!file_exists($trashPath)) {
jsonResponse(false, null, 'Trash file not found');
}
// Extract original page name (remove timestamp suffix)
$pageName = preg_replace('/_\d{4}-\d{2}-\d{2}_\d{6}(?:_[a-f0-9]{6})?\.json$/', '', $trashFile);
$targetPath = CONTENT_PATH . $pageName . '.json';
// If a page with the same name already exists, abort
if (file_exists($targetPath)) {
jsonResponse(false, null, 'A page with this name already exists. Delete or rename it first.');
}
if (!rename($trashPath, $targetPath)) {
jsonResponse(false, null, 'Error restoring page');
}
jsonResponse(true, ['page' => $pageName, 'pageList' => buildPageList()], 'Page restored');
break;
case 'delete-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$trashFile = $_POST['filename'] ?? '';
if (empty($trashFile) || !validateBackupName($trashFile)) {
jsonResponse(false, null, 'Invalid trash filename');
}
$trashPath = PAGES_TRASH_PATH . $trashFile;
if (!file_exists($trashPath)) {
jsonResponse(false, null, 'Trash file not found');
}
if (!unlink($trashPath)) {
jsonResponse(false, null, 'Error deleting permanently');
}
jsonResponse(true, null, 'Page permanently deleted');
break;
case 'empty-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!is_dir(PAGES_TRASH_PATH)) {
jsonResponse(true, null, 'Trash is already empty');
}
$files = glob(PAGES_TRASH_PATH . '*.json');
$deleted = 0;
foreach ($files as $file) {
if (unlink($file)) {
$deleted++;
}
}
jsonResponse(true, ['deleted' => $deleted], $deleted . ' page(s) permanently deleted');
break;
case 'load':
$page = $_GET['page'] ?? $_POST['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$filepath = CONTENT_PATH . $page . '.json';
if (!file_exists($filepath)) {
jsonResponse(true, [
'page' => $page,
'lastModified' => null,
'sections' => []
]);
}
$content = json_decode(file_get_contents($filepath), true);
jsonResponse(true, $content);
break;
case 'save':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$page = $_POST['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$content = $_POST['content'] ?? '';
$contentData = json_decode($content, true);
if (!is_array($contentData)) {
jsonResponse(false, null, 'Invalid JSON format');
}
$filepath = CONTENT_PATH . $page . '.json';
$existingData = file_exists($filepath) ? (json_decode(file_get_contents($filepath), true) ?: []) : [];
$contentData = normalizePageVisibility($contentData, $existingData);
$contentData = normalizePageSeo($contentData);
// Create backup if file exists
if (file_exists($filepath)) {
$timestamp = date('Y-m-d_His') . '_' . bin2hex(random_bytes(3));
$backupPath = BACKUP_PATH . $page . '_' . $timestamp . '.json';
if (!copy($filepath, $backupPath)) jsonResponse(false, null, 'Could not create a backup before saving');
cleanupOldBackups($page);
}
$contentData['lastModified'] = date('c');
$result = raijuJsonAtomicWrite($filepath, $contentData);
if ($result === false) {
jsonResponse(false, null, 'Error saving');
}
$responseData = ['lastModified' => $contentData['lastModified']];
if (($pageParts = raijuPageParseContentKey($page)) !== null) {
$responseData['seoHealth'] = buildPageSeoHealth($pageParts['lang'], $pageParts['path'], $contentData);
}
// Optional: re-render the full sections list so the client can patch
// the .editable-content-area DOM without a full page reload (used
// after add/delete/reorder). Returning the whole list keeps card-grid
// grouping and index-based data-field attributes in sync.
if (isset($GLOBALS['raijuRevisionLock']) && is_resource($GLOBALS['raijuRevisionLock'])) {
flock($GLOBALS['raijuRevisionLock'], LOCK_UN); fclose($GLOBALS['raijuRevisionLock']);
}
if (!empty($_POST['render_sections'])) {
require_once RAIJU_ADMIN_DIR . '/../includes/content-loader.php';
// Force admin mode for the renderer: we're already authenticated
// via the save endpoint, but renderAllSections checks isAdminLoggedIn.
$responseData['sectionsHtml'] = renderAllSections($page);
}
jsonResponse(true, $responseData, 'Saved successfully');
break;
// ============================================================
// BACKUPS
// ============================================================
case 'backups':
$page = $_GET['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$backups = glob(BACKUP_PATH . $page . '_*.json');
$backupList = [];
foreach ($backups as $backup) {
$filename = basename($backup);
if (preg_match('/^' . preg_quote($page, '/') . '_(\d{4}-\d{2}-\d{2})_(\d{6})(?:_[a-f0-9]{6})?\.json$/', $filename, $matches)) {
$date = $matches[1];
$time = substr($matches[2], 0, 2) . ':' . substr($matches[2], 2, 2) . ':' . substr($matches[2], 4, 2);
$backupList[] = [
'filename' => $filename,
'date' => $date,
'time' => $time,
'timestamp' => filemtime($backup)
];
}
}
usort($backupList, function($a, $b) {
return $b['timestamp'] - $a['timestamp'];
});
jsonResponse(true, $backupList);
break;
case 'restore':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$backup = $_POST['backup'] ?? '';
if (!validateBackupName($backup)) {
jsonResponse(false, null, 'Invalid backup name');
}
$backupPath = BACKUP_PATH . $backup;
if (!file_exists($backupPath)) {
jsonResponse(false, null, 'Backup not found');
}
$page = preg_replace('/_\d{4}-\d{2}-\d{2}_\d{6}(?:_[a-f0-9]{6})?\.json$/', '', $backup);
$filepath = CONTENT_PATH . $page . '.json';
if (isset($_POST['page']) && $_POST['page'] !== $page) {
http_response_code(400);
jsonResponse(false, null, 'Backup does not belong to the requested page');
}
// Save current state before restoring
if (file_exists($filepath)) {
$timestamp = date('Y-m-d_His') . '_' . bin2hex(random_bytes(3));
$newBackupPath = BACKUP_PATH . $page . '_' . $timestamp . '.json';
if (!copy($filepath, $newBackupPath)) {
jsonResponse(false, null, 'Could not create a backup before restoring');
}
}
$restoredData = json_decode((string)file_get_contents($backupPath), true);
$result = is_array($restoredData) && raijuJsonAtomicWrite($filepath, $restoredData);
if (!$result) {
jsonResponse(false, null, 'Error restoring');
}
cleanupOldBackups($page);
jsonResponse(true, null, 'Backup restored successfully');
break;
case 'delete-backup':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$backup = $_POST['backup'] ?? '';
if (!validateBackupName($backup)) {
jsonResponse(false, null, 'Invalid backup name');
}
$backupPath = BACKUP_PATH . $backup;
if (!file_exists($backupPath)) {
jsonResponse(false, null, 'Backup not found');
}
$result = unlink($backupPath);
if (!$result) {
jsonResponse(false, null, 'Error deleting');
}
jsonResponse(true, null, 'Backup deleted');
break;
case 'preview-backup':
$backup = $_GET['backup'] ?? '';
if (!validateBackupName($backup)) {
jsonResponse(false, null, 'Invalid backup name');
}
$backupPath = BACKUP_PATH . $backup;
if (!file_exists($backupPath)) {
jsonResponse(false, null, 'Backup not found');
}
$content = json_decode(file_get_contents($backupPath), true);
jsonResponse(true, $content);
break;
// ============================================================
// EVENTS
// ============================================================
}