Raiju/admin/api/backups.php
touche 3c591a287a
Some checks are pending
Raiju CI / PHP ${{ matrix.php }} (8.1) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.4) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.5) (push) Waiting to run
Raiju CI / Apache routing / PHP-FPM (push) Waiting to run
Raiju CI / Chromium (push) Waiting to run
Brand: capitalize Raiju in content, UI strings and prompts
- Starter content and admin language files: Raiju, not raiju
- Test email, backup messages, mailer header and AI prompts use Raiju
- Meta generator and footer fallback use Raiju
- Technical identifiers (paths, CSS classes, user agents, temp prefixes) unchanged
2026-10-25 06:30:17 +03:00

1001 lines
46 KiB
PHP

<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'create-site-backup':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
// Tag as "manual" so the prune algorithm protects this backup
// from automatic eviction — the admin explicitly asked for it.
try {
$created = backupWithLock(fn() => backupCreate('manual'));
} catch (BackupLockException $e) {
http_response_code(409);
jsonResponse(false, null, $e->getMessage());
}
if (!$created['ok']) {
jsonResponse(false, null, $created['message']);
}
// One-time download token. The file stays in the backup pool
// after download (unlike the previous flow which deleted on
// download) so it's still available later for restore.
$token = bin2hex(random_bytes(32));
$_SESSION['backup_download'] = [
'token' => $token,
'file' => $created['file'],
'created' => time()
];
jsonResponse(true, ['token' => $token, 'filename' => $created['file']], 'Backup created');
break;
case 'download-site-backup':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
// Validate one-time token
if (!isset($_SESSION['backup_download'])) {
http_response_code(403);
jsonResponse(false, null, 'No backup download pending.');
}
$backupInfo = $_SESSION['backup_download'];
$providedToken = $_GET['token'] ?? '';
if (!hash_equals($backupInfo['token'], $providedToken)) {
http_response_code(403);
jsonResponse(false, null, 'Invalid download token.');
}
// Token expires after 5 minutes. The backup file stays — it's
// a manual backup in the pool, the user can still download it
// later via the scheduled-backup list (which generates a fresh
// token).
if (time() - $backupInfo['created'] > 300) {
unset($_SESSION['backup_download']);
http_response_code(410);
jsonResponse(false, null, 'Download token expired.');
}
$zipPath = BACKUP_PATH . $backupInfo['file'];
if (!is_file($zipPath)) {
unset($_SESSION['backup_download']);
http_response_code(404);
jsonResponse(false, null, 'Backup file not found.');
}
// Consume the token (one-time use)
$downloadName = $_GET['filename'] ?? 'site-backup.zip';
$downloadName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $downloadName);
unset($_SESSION['backup_download']);
// Release session lock before streaming
session_write_close();
// Clear any output buffers
while (ob_get_level()) {
ob_end_clean();
}
// Send ZIP headers
header_remove('Content-Type');
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
// Stream file. Don't delete — the backup is now part of the
// pool (tagged "manual") and the admin may want to keep it
// server-side as well.
readfile($zipPath);
exit;
case 'restore-site-backup':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!class_exists('ZipArchive')) {
jsonResponse(false, null, 'ZIP extension not available on this server.');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
// Source can be either an uploaded ZIP for off-server restore
// or a pool file (the dashboard list
// lets the admin restore an existing backup without uploading
// it first). Normalise both to $uploadedFile + $maxSize.
$poolFile = $_POST['pool_file'] ?? '';
if ($poolFile !== '') {
if (!backupIsPoolFilename($poolFile)) {
jsonResponse(false, null, 'Invalid backup filename');
}
$uploadedFile = BACKUP_PATH . $poolFile;
if (!is_file($uploadedFile)) {
jsonResponse(false, null, 'Backup not found in pool');
}
} else {
// Validate upload
if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) {
$uploadErrors = [
UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit.',
UPLOAD_ERR_FORM_SIZE => 'File exceeds form upload limit.',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.',
UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Server temporary folder missing.',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
];
$code = $_FILES['backup_zip']['error'] ?? UPLOAD_ERR_NO_FILE;
jsonResponse(false, null, $uploadErrors[$code] ?? 'Upload failed.');
}
$uploadedFile = $_FILES['backup_zip']['tmp_name'];
}
$mode = $_POST['restore_mode'] ?? '';
if (!in_array($mode, ['full', 'content'])) {
jsonResponse(false, null, 'Invalid restore mode.');
}
$maxSize = 500 * 1024 * 1024; // 500 MB
if (filesize($uploadedFile) > $maxSize) {
jsonResponse(false, null, 'File too large (max 500 MB).');
}
// Open and validate ZIP
$zip = new ZipArchive();
$result = $zip->open($uploadedFile);
if ($result !== true) {
jsonResponse(false, null, 'Invalid or corrupted ZIP file.');
}
// Collect all entries and run security checks
$entries = [];
$hasContentPage = false;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
// Path traversal check
if (str_contains($name, '..') || str_starts_with($name, '/')) {
$zip->close();
jsonResponse(false, null, 'ZIP contains unsafe paths (path traversal detected).');
}
$entries[] = $name;
// Check for content pages
if (preg_match('#^content/pages/[a-z]{2}_[a-z0-9_-]+\.json$#i', $name)) {
$hasContentPage = true;
}
}
// Structure checks: required Raiju files must be present
$requiredFiles = backupRestoreRequiredFiles();
$missingFiles = [];
foreach ($requiredFiles as $req) {
if ($zip->locateName($req) === false) {
$missingFiles[] = $req;
}
}
if (!empty($missingFiles)) {
$zip->close();
jsonResponse(false, null, 'Not a valid Raiju backup. Missing: ' . implode(', ', $missingFiles));
}
if (!$hasContentPage) {
$zip->close();
jsonResponse(false, null, 'Not a valid Raiju backup. No content pages found.');
}
// File extension whitelist
$allowedExtensions = backupRestoreAllowedExtensions();
$rejectedPhpFiles = [];
foreach ($entries as $entry) {
// Skip directories
if (str_ends_with($entry, '/')) continue;
// Older full-site backups may contain development tests. Accept
// the archive, but do not treat or restore these as site PHP.
if (backupRestoreEntryIgnored($entry)) continue;
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
// Check PHP files are in allowed locations
if ($ext === 'php' && !backupRestorePhpEntryAllowed($entry)) {
$rejectedPhpFiles[] = $entry;
}
// Check file extension whitelist (skip dirs)
if ($ext !== '' && !in_array($ext, $allowedExtensions) && basename($entry) !== '.htaccess') {
// Silently skip — will not extract these files
}
}
if (!empty($rejectedPhpFiles)) {
$zip->close();
jsonResponse(false, null, 'ZIP contains PHP files in unexpected locations: ' . implode(', ', array_slice($rejectedPhpFiles, 0, 5)));
}
$siteRoot = realpath(RAIJU_ADMIN_DIR . '/..');
require_once RAIJU_ADMIN_DIR . '/../includes/restore-helper.php';
$selected = [];
$skipped = 0;
foreach ($entries as $entry) {
if (str_ends_with($entry, '/')) continue;
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
if (backupRestoreEntryIgnored($entry)
|| ($ext !== '' && !in_array($ext, $allowedExtensions, true) && basename($entry) !== '.htaccess')
|| ($mode === 'content' && !backupRestoreContentEntryAllowed($entry))) {
$skipped++;
continue;
}
$selected[] = $entry;
}
try {
$extracted = backupWithLock(function () use ($zip, $selected, $siteRoot, $mode): int {
if ($mode === 'full') {
$safetyBackup = backupCreate('manual');
if (empty($safetyBackup['ok'])) throw new RuntimeException('Pre-restore backup failed: ' . ($safetyBackup['message'] ?? 'unknown error'));
}
return raijuRestoreFiles($zip, $selected, $siteRoot, $mode === 'content');
});
} catch (Throwable $error) {
$zip->close();
jsonResponse(false, null, $error->getMessage());
}
$zip->close();
jsonResponse(true, [
'extracted' => $extracted,
'skipped' => $skipped,
'mode' => $mode,
], $mode === 'full' ? 'Full site restored' : 'Content restored');
break;
// ============================================================
// SCHEDULED BACKUPS — pool of *-backup-*-{tier}.zip files
// ============================================================
// These endpoints back the dashboard's "Automated backups" UI and
// complement the manual create/download/restore flow above. The
// ZIP-creation, retention, and tier logic lives in
// includes/backup-helper.php so the cron CLI uses the exact same
// code path as the admin UI.
case 'backup-status':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
jsonResponse(true, backupStatus());
break;
case 'backup-list':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
jsonResponse(true, ['backups' => backupListAll()]);
break;
case 'backup-create-now':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
// Tag as "manual" — won't be auto-evicted by storage budget.
try {
$created = backupWithLock(fn() => backupCreate('manual'));
} catch (BackupLockException $e) {
http_response_code(409);
jsonResponse(false, null, $e->getMessage());
}
if (!$created['ok']) {
jsonResponse(false, null, $created['message']);
}
jsonResponse(true, $created, 'Backup created');
break;
case 'backup-prune':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
try {
$deleted = backupWithLock(fn() => backupPrune());
} catch (BackupLockException $e) {
http_response_code(409);
jsonResponse(false, null, $e->getMessage());
}
jsonResponse(true, ['deleted' => $deleted], count($deleted) . ' backup(s) pruned');
break;
case 'backup-delete':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$file = $_POST['file'] ?? '';
if (!backupIsPoolFilename($file)) {
jsonResponse(false, null, 'Invalid backup filename');
}
$path = BACKUP_PATH . $file;
if (!is_file($path)) {
jsonResponse(false, null, 'Backup not found');
}
if (!@unlink($path)) {
jsonResponse(false, null, 'Could not delete backup');
}
jsonResponse(true, null, 'Backup deleted');
break;
case 'backup-upload-remote':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$file = $_POST['file'] ?? '';
$targetId = $_POST['target_id'] ?? '';
if (!backupIsPoolFilename($file)) {
jsonResponse(false, null, 'Invalid backup filename');
}
if (!is_file(BACKUP_PATH . $file)) {
jsonResponse(false, null, 'Backup not found');
}
try {
$results = backupWithLock(fn() => backupUploadRemoteTargets($file, $targetId !== '' ? $targetId : null));
} catch (BackupLockException $e) {
http_response_code(409);
jsonResponse(false, null, $e->getMessage());
}
$failed = array_values(array_filter($results, fn($result) => empty($result['ok'])));
jsonResponse(empty($failed), ['results' => $results, 'status' => backupStatus()], empty($failed) ? 'Remote upload complete' : ($failed[0]['message'] ?? 'Remote upload failed'));
break;
case 'backup-remote-list':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$target = backupRemoteTargetById($_GET['target_id'] ?? '');
if (!$target) jsonResponse(false, null, 'Remote target not found');
try {
backupRemoteRefreshTarget($target);
backupSaveRemoteTarget($target);
$result = backupRemoteList($target);
jsonResponse(!empty($result['ok']), ['files' => $result['files'] ?? []], $result['message'] ?? '');
} catch (Throwable $e) {
jsonResponse(false, ['files' => []], $e->getMessage());
}
break;
case 'backup-remote-import':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$target = backupRemoteTargetById($_POST['target_id'] ?? '');
$file = $_POST['file'] ?? '';
if (!$target) jsonResponse(false, null, 'Remote target not found');
if (!backupIsPoolFilename($file)) jsonResponse(false, null, 'Invalid backup filename');
if (!is_dir(BACKUP_PATH)) @mkdir(BACKUP_PATH, 0755, true);
$destination = BACKUP_PATH . $file;
if (is_file($destination)) jsonResponse(true, ['file' => $file, 'status' => backupStatus()], 'Backup already exists locally');
try {
backupRemoteRefreshTarget($target);
backupSaveRemoteTarget($target);
$result = backupRemoteDownload($file, $target, $destination);
} catch (Throwable $e) {
@unlink($destination);
jsonResponse(false, null, $e->getMessage());
}
if (empty($result['ok'])) {
@unlink($destination);
jsonResponse(false, null, $result['message']);
}
jsonResponse(true, ['file' => $file, 'status' => backupStatus()], 'Backup imported');
break;
case 'backup-remote-delete':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$target = backupRemoteTargetById($_POST['target_id'] ?? '');
$file = $_POST['file'] ?? '';
if (!$target) jsonResponse(false, null, 'Remote target not found');
try {
backupRemoteRefreshTarget($target);
backupSaveRemoteTarget($target);
$result = backupRemoteDelete($file, $target);
jsonResponse(!empty($result['ok']), null, $result['message'] ?? '');
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'backup-test-remote':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$targetId = $_POST['target_id'] ?? '';
$config = backupConfig();
$target = null;
foreach ($config['remote_targets'] as $candidate) {
if ($candidate['id'] === $targetId) {
$target = $candidate;
break;
}
}
if (!$target) {
jsonResponse(false, null, 'Remote target not found');
}
if (!is_dir(BACKUP_PATH)) @mkdir(BACKUP_PATH, 0755, true);
$testFile = BACKUP_PATH . 'raiju-remote-test-' . date('Ymd-His') . '.txt';
file_put_contents($testFile, "Raiju remote backup test\n" . date('c') . "\n");
try {
backupRemoteRefreshTarget($target);
$result = backupRemoteUpload($testFile, $target);
} catch (Throwable $e) {
$result = ['ok' => false, 'message' => $e->getMessage()];
}
@unlink($testFile);
$target['last_upload'] = date('c');
$target['last_status'] = $result['ok'] ? 'success' : 'error';
$target['last_message'] = $result['message'];
$target['last_file'] = basename($testFile);
foreach ($config['remote_targets'] as $idx => $candidate) {
if ($candidate['id'] === $targetId) {
$config['remote_targets'][$idx] = $target;
break;
}
}
backupSaveConfig(['remote_targets' => $config['remote_targets']]);
jsonResponse($result['ok'], ['status' => backupStatus()], $result['message']);
break;
case 'backup-dropbox-oauth-start':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$targetId = $_GET['target_id'] ?? '';
$config = backupConfig();
$target = null;
foreach ($config['remote_targets'] as $candidate) {
if ($candidate['id'] === $targetId && $candidate['type'] === 'dropbox') {
$target = $candidate;
break;
}
}
if (!$target) jsonResponse(false, null, 'Dropbox target not found');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/api.php'), '/\\');
$brokerUrl = backupRemoteOAuthBrokerUrl();
if ($brokerUrl !== '') {
$state = bin2hex(random_bytes(24));
$exchangeSecret = bin2hex(random_bytes(32));
$_SESSION['backup_dropbox_broker_oauth'] = [
'state' => $state,
'target_id' => $targetId,
'exchange_secret' => $exchangeSecret,
'created' => time(),
];
$returnUrl = $scheme . '://' . $host . $base . '/api.php?action=backup-dropbox-broker-callback';
$brokerStartUrl = $brokerUrl . '/dropbox/start?' . http_build_query([
'return_url' => $returnUrl,
'state' => $state,
'exchange_challenge' => hash('sha256', $exchangeSecret),
]);
header_remove('Content-Type');
header('Location: ' . $brokerStartUrl, true, 302);
exit;
}
$appKey = $target['settings']['app_key'] ?? '';
if ($appKey === '') $appKey = backupRemoteGlobalOAuthValue('dropbox', 'app_key');
if ($appKey === '') jsonResponse(false, null, 'Dropbox app key is missing');
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$state = bin2hex(random_bytes(24));
$_SESSION['backup_dropbox_oauth'] = [
'state' => $state,
'target_id' => $targetId,
'code_verifier' => $verifier,
'created' => time(),
];
$redirectUri = $scheme . '://' . $host . $base . '/api.php?action=backup-dropbox-oauth-callback';
$authUrl = 'https://www.dropbox.com/oauth2/authorize?' . http_build_query([
'client_id' => $appKey,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'state' => $state,
'scope' => 'files.content.write files.content.read files.metadata.read',
'token_access_type' => 'offline',
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
header_remove('Content-Type');
header('Location: ' . $authUrl, true, 302);
exit;
case 'backup-dropbox-oauth-callback':
if (!isAdmin()) redirectHtml('Dropbox connection failed', 'Your admin session is no longer active.');
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$pending = $_SESSION['backup_dropbox_oauth'] ?? null;
if (!is_array($pending) || time() - ($pending['created'] ?? 0) > 600) {
redirectHtml('Dropbox connection failed', 'The authorization request expired.');
}
if (!hash_equals($pending['state'], $_GET['state'] ?? '')) {
redirectHtml('Dropbox connection failed', 'The authorization state did not match.');
}
$code = $_GET['code'] ?? '';
if ($code === '') {
redirectHtml('Dropbox connection failed', $_GET['error_description'] ?? ($_GET['error'] ?? 'Dropbox did not return an authorization code.'));
}
$config = backupConfig();
$targetIndex = null;
foreach ($config['remote_targets'] as $idx => $candidate) {
if ($candidate['id'] === $pending['target_id'] && $candidate['type'] === 'dropbox') {
$targetIndex = $idx;
break;
}
}
if ($targetIndex === null) redirectHtml('Dropbox connection failed', 'Dropbox target no longer exists.');
$target = $config['remote_targets'][$targetIndex];
$appKey = $target['settings']['app_key'] ?? '';
if ($appKey === '') $appKey = backupRemoteGlobalOAuthValue('dropbox', 'app_key');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/api.php'), '/\\');
$redirectUri = $scheme . '://' . $host . $base . '/api.php?action=backup-dropbox-oauth-callback';
try {
$tokenResult = backupRemoteCurl('https://api.dropboxapi.com/oauth2/token', [
'method' => 'POST',
'headers' => ['Content-Type: application/x-www-form-urlencoded'],
'body' => http_build_query([
'code' => $code,
'grant_type' => 'authorization_code',
'client_id' => $appKey,
'redirect_uri' => $redirectUri,
'code_verifier' => $pending['code_verifier'],
]),
'timeout' => 60,
]);
} catch (Throwable $e) {
redirectHtml('Dropbox connection failed', $e->getMessage());
}
$tokenJson = json_decode($tokenResult['body'], true);
if (!is_array($tokenJson) || empty($tokenJson['access_token'])) {
redirectHtml('Dropbox connection failed', 'Dropbox token exchange failed.');
}
$target['settings']['access_token'] = $tokenJson['access_token'];
if (!empty($tokenJson['refresh_token'])) {
$target['settings']['refresh_token'] = $tokenJson['refresh_token'];
}
if (!empty($tokenJson['expires_in'])) {
$target['settings']['expires_at'] = time() + (int)$tokenJson['expires_in'];
}
if (!empty($tokenJson['account_id'])) {
$target['settings']['account_id'] = $tokenJson['account_id'];
}
$target['last_status'] = 'success';
$target['last_message'] = 'Dropbox connected.';
$target['last_upload'] = date('c');
$config['remote_targets'][$targetIndex] = $target;
backupSaveConfig(['remote_targets' => $config['remote_targets']]);
unset($_SESSION['backup_dropbox_oauth']);
redirectHtml('Dropbox connected', 'You can close this tab and return to Raiju.', 'dashboard');
case 'backup-dropbox-broker-callback':
if (!isAdmin()) redirectHtml('Dropbox connection failed', 'Your admin session is no longer active.');
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$pending = $_SESSION['backup_dropbox_broker_oauth'] ?? null;
if (!is_array($pending) || time() - ($pending['created'] ?? 0) > 600) {
redirectHtml('Dropbox connection failed', 'The authorization request expired.');
}
if (!hash_equals($pending['state'], $_GET['state'] ?? '')) {
redirectHtml('Dropbox connection failed', 'The authorization state did not match.');
}
$exchangeId = $_GET['exchange_id'] ?? '';
if (!preg_match('/^[a-f0-9]{32,64}$/', $exchangeId)) {
redirectHtml('Dropbox connection failed', 'The auth broker did not return a valid exchange ID.');
}
$brokerUrl = backupRemoteOAuthBrokerUrl();
try {
$exchangeResult = backupRemoteCurl($brokerUrl . '/token/exchange', [
'method' => 'POST',
'headers' => ['Content-Type: application/x-www-form-urlencoded'],
'body' => http_build_query([
'provider' => 'dropbox',
'exchange_id' => $exchangeId,
'exchange_secret' => $pending['exchange_secret'],
]),
'timeout' => 60,
]);
} catch (Throwable $e) {
redirectHtml('Dropbox connection failed', $e->getMessage());
}
$exchangeJson = json_decode($exchangeResult['body'], true);
if (!is_array($exchangeJson) || empty($exchangeJson['ok']) || empty($exchangeJson['token']['access_token'])) {
redirectHtml('Dropbox connection failed', 'The auth broker token exchange failed.');
}
$config = backupConfig();
$targetIndex = null;
foreach ($config['remote_targets'] as $idx => $candidate) {
if ($candidate['id'] === $pending['target_id'] && $candidate['type'] === 'dropbox') {
$targetIndex = $idx;
break;
}
}
if ($targetIndex === null) redirectHtml('Dropbox connection failed', 'Dropbox target no longer exists.');
$target = $config['remote_targets'][$targetIndex];
$tokenJson = $exchangeJson['token'];
$target['settings']['access_token'] = $tokenJson['access_token'];
if (!empty($tokenJson['refresh_token'])) {
$target['settings']['refresh_token'] = $tokenJson['refresh_token'];
}
if (!empty($tokenJson['expires_in'])) {
$target['settings']['expires_at'] = time() + (int)$tokenJson['expires_in'];
}
if (!empty($tokenJson['account_id'])) {
$target['settings']['account_id'] = $tokenJson['account_id'];
}
$target['settings']['oauth_broker'] = backupRemoteOAuthBrokerUrl();
$target['last_status'] = 'success';
$target['last_message'] = 'Dropbox connected.';
$target['last_upload'] = date('c');
$config['remote_targets'][$targetIndex] = $target;
backupSaveConfig(['remote_targets' => $config['remote_targets']]);
unset($_SESSION['backup_dropbox_broker_oauth']);
redirectHtml('Dropbox connected', 'You can close this tab and return to Raiju.', 'dashboard');
case 'backup-google-oauth-start':
case 'backup-onedrive-oauth-start':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$type = $action === 'backup-google-oauth-start' ? 'google_drive' : 'onedrive';
$label = $type === 'google_drive' ? 'Google Drive' : 'OneDrive';
$targetId = $_GET['target_id'] ?? '';
$config = backupConfig();
$target = null;
foreach ($config['remote_targets'] as $candidate) {
if ($candidate['id'] === $targetId && $candidate['type'] === $type) {
$target = $candidate;
break;
}
}
if (!$target) jsonResponse(false, null, "$label target not found");
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/api.php'), '/\\');
$brokerUrl = backupRemoteOAuthBrokerUrl();
if (($type === 'google_drive' || $type === 'onedrive') && $brokerUrl !== '') {
$state = bin2hex(random_bytes(24));
$exchangeSecret = bin2hex(random_bytes(32));
$brokerSessionKey = $type === 'google_drive' ? 'backup_google_broker_oauth' : 'backup_onedrive_broker_oauth';
$brokerPath = $type === 'google_drive' ? 'google' : 'onedrive';
$brokerCallbackAction = $type === 'google_drive' ? 'backup-google-broker-callback' : 'backup-onedrive-broker-callback';
$_SESSION[$brokerSessionKey] = [
'state' => $state,
'target_id' => $targetId,
'exchange_secret' => $exchangeSecret,
'created' => time(),
];
$returnUrl = $scheme . '://' . $host . $base . '/api.php?action=' . $brokerCallbackAction;
$brokerStartUrl = $brokerUrl . '/' . $brokerPath . '/start?' . http_build_query([
'return_url' => $returnUrl,
'state' => $state,
'exchange_challenge' => hash('sha256', $exchangeSecret),
]);
header_remove('Content-Type');
header('Location: ' . $brokerStartUrl, true, 302);
exit;
}
$clientId = $target['settings']['client_id'] ?? '';
if ($clientId === '') $clientId = backupRemoteGlobalOAuthValue($type, 'client_id');
if ($clientId === '') jsonResponse(false, null, "$label client ID is missing");
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$state = bin2hex(random_bytes(24));
$_SESSION['backup_oauth'] = [
'provider' => $type,
'state' => $state,
'target_id' => $targetId,
'code_verifier' => $verifier,
'created' => time(),
];
if ($type === 'google_drive') {
$redirectUri = $scheme . '://' . $host . $base . '/api.php?action=backup-google-oauth-callback';
$authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'state' => $state,
'scope' => 'https://www.googleapis.com/auth/drive.file',
'access_type' => 'offline',
'include_granted_scopes' => 'true',
'prompt' => 'consent',
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
} else {
$redirectUri = $scheme . '://' . $host . $base . '/api.php?action=backup-onedrive-oauth-callback';
$authUrl = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' . http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'state' => $state,
'scope' => 'offline_access Files.ReadWrite.AppFolder',
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
}
header_remove('Content-Type');
header('Location: ' . $authUrl, true, 302);
exit;
case 'backup-google-broker-callback':
case 'backup-onedrive-broker-callback':
$type = $action === 'backup-google-broker-callback' ? 'google_drive' : 'onedrive';
$label = $type === 'google_drive' ? 'Google Drive' : 'OneDrive';
$sessionKey = $type === 'google_drive' ? 'backup_google_broker_oauth' : 'backup_onedrive_broker_oauth';
if (!isAdmin()) redirectHtml("$label connection failed", 'Your admin session is no longer active.');
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$pending = $_SESSION[$sessionKey] ?? null;
if (!is_array($pending) || time() - ($pending['created'] ?? 0) > 600) {
redirectHtml("$label connection failed", 'The authorization request expired.');
}
if (!hash_equals($pending['state'], $_GET['state'] ?? '')) {
redirectHtml("$label connection failed", 'The authorization state did not match.');
}
$exchangeId = $_GET['exchange_id'] ?? '';
if (!preg_match('/^[a-f0-9]{32,64}$/', $exchangeId)) {
redirectHtml("$label connection failed", 'The auth broker did not return a valid exchange ID.');
}
$brokerUrl = backupRemoteOAuthBrokerUrl();
try {
$exchangeResult = backupRemoteCurl($brokerUrl . '/token/exchange', [
'method' => 'POST',
'headers' => ['Content-Type: application/x-www-form-urlencoded'],
'body' => http_build_query([
'provider' => $type,
'exchange_id' => $exchangeId,
'exchange_secret' => $pending['exchange_secret'],
]),
'timeout' => 60,
]);
} catch (Throwable $e) {
redirectHtml("$label connection failed", $e->getMessage());
}
$exchangeJson = json_decode($exchangeResult['body'], true);
if (!is_array($exchangeJson) || empty($exchangeJson['ok']) || empty($exchangeJson['token']['access_token'])) {
redirectHtml("$label connection failed", 'The auth broker token exchange failed.');
}
$config = backupConfig();
$targetIndex = null;
foreach ($config['remote_targets'] as $idx => $candidate) {
if ($candidate['id'] === $pending['target_id'] && $candidate['type'] === $type) {
$targetIndex = $idx;
break;
}
}
if ($targetIndex === null) redirectHtml("$label connection failed", "$label target no longer exists.");
$target = $config['remote_targets'][$targetIndex];
$tokenJson = $exchangeJson['token'];
$target['settings']['access_token'] = $tokenJson['access_token'];
if (!empty($tokenJson['refresh_token'])) {
$target['settings']['refresh_token'] = $tokenJson['refresh_token'];
}
if (!empty($tokenJson['expires_in'])) {
$target['settings']['expires_at'] = time() + (int)$tokenJson['expires_in'];
}
if (!empty($tokenJson['client_id'])) {
$target['settings']['client_id'] = $tokenJson['client_id'];
}
$target['settings']['oauth_broker'] = backupRemoteOAuthBrokerUrl();
$target['last_status'] = 'success';
$target['last_message'] = "$label connected.";
$target['last_upload'] = date('c');
$config['remote_targets'][$targetIndex] = $target;
backupSaveConfig(['remote_targets' => $config['remote_targets']]);
unset($_SESSION[$sessionKey]);
redirectHtml("$label connected", 'You can close this tab and return to Raiju.', 'dashboard');
case 'backup-google-oauth-callback':
case 'backup-onedrive-oauth-callback':
$type = $action === 'backup-google-oauth-callback' ? 'google_drive' : 'onedrive';
$label = $type === 'google_drive' ? 'Google Drive' : 'OneDrive';
if (!isAdmin()) redirectHtml("$label connection failed", 'Your admin session is no longer active.');
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$pending = $_SESSION['backup_oauth'] ?? null;
if (!is_array($pending) || ($pending['provider'] ?? '') !== $type || time() - ($pending['created'] ?? 0) > 600) {
redirectHtml("$label connection failed", 'The authorization request expired.');
}
if (!hash_equals($pending['state'], $_GET['state'] ?? '')) {
redirectHtml("$label connection failed", 'The authorization state did not match.');
}
$code = $_GET['code'] ?? '';
if ($code === '') {
redirectHtml("$label connection failed", $_GET['error_description'] ?? ($_GET['error'] ?? "$label did not return an authorization code."));
}
$config = backupConfig();
$targetIndex = null;
foreach ($config['remote_targets'] as $idx => $candidate) {
if ($candidate['id'] === $pending['target_id'] && $candidate['type'] === $type) {
$targetIndex = $idx;
break;
}
}
if ($targetIndex === null) redirectHtml("$label connection failed", "$label target no longer exists.");
$target = $config['remote_targets'][$targetIndex];
$clientId = $target['settings']['client_id'] ?? '';
if ($clientId === '') $clientId = backupRemoteGlobalOAuthValue($type, 'client_id');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/api.php'), '/\\');
if ($type === 'google_drive') {
$redirectUri = $scheme . '://' . $host . $base . '/api.php?action=backup-google-oauth-callback';
$tokenUrl = 'https://oauth2.googleapis.com/token';
$tokenBody = [
'code' => $code,
'grant_type' => 'authorization_code',
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'code_verifier' => $pending['code_verifier'],
];
$clientSecret = $target['settings']['client_secret'] ?? backupRemoteGlobalOAuthValue('google_drive', 'client_secret');
if ($clientSecret !== '') $tokenBody['client_secret'] = $clientSecret;
} else {
$redirectUri = $scheme . '://' . $host . $base . '/api.php?action=backup-onedrive-oauth-callback';
$tokenUrl = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
$tokenBody = [
'code' => $code,
'grant_type' => 'authorization_code',
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'code_verifier' => $pending['code_verifier'],
'scope' => 'offline_access Files.ReadWrite.AppFolder',
];
$clientSecret = $target['settings']['client_secret'] ?? backupRemoteGlobalOAuthValue('onedrive', 'client_secret');
if ($clientSecret !== '') $tokenBody['client_secret'] = $clientSecret;
}
try {
$tokenResult = backupRemoteCurl($tokenUrl, [
'method' => 'POST',
'headers' => ['Content-Type: application/x-www-form-urlencoded'],
'body' => http_build_query($tokenBody),
'timeout' => 60,
]);
} catch (Throwable $e) {
redirectHtml("$label connection failed", $e->getMessage());
}
$tokenJson = json_decode($tokenResult['body'], true);
if (!is_array($tokenJson) || empty($tokenJson['access_token'])) {
redirectHtml("$label connection failed", "$label token exchange failed.");
}
$target['settings']['access_token'] = $tokenJson['access_token'];
if (!empty($tokenJson['refresh_token'])) {
$target['settings']['refresh_token'] = $tokenJson['refresh_token'];
}
if (!empty($tokenJson['expires_in'])) {
$target['settings']['expires_at'] = time() + (int)$tokenJson['expires_in'];
}
$target['last_status'] = 'success';
$target['last_message'] = "$label connected.";
$target['last_upload'] = date('c');
$config['remote_targets'][$targetIndex] = $target;
backupSaveConfig(['remote_targets' => $config['remote_targets']]);
unset($_SESSION['backup_oauth']);
redirectHtml("$label connected", 'You can close this tab and return to Raiju.', 'dashboard');
case 'backup-prepare-download':
// Issues a one-time token for downloading an existing backup
// from the pool. The download itself reuses the existing
// download-site-backup endpoint — same token format.
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$file = $_POST['file'] ?? '';
if (!backupIsPoolFilename($file)) {
jsonResponse(false, null, 'Invalid backup filename');
}
if (!is_file(BACKUP_PATH . $file)) {
jsonResponse(false, null, 'Backup not found');
}
$token = bin2hex(random_bytes(32));
$_SESSION['backup_download'] = [
'token' => $token,
'file' => $file,
'created' => time(),
];
jsonResponse(true, ['token' => $token, 'filename' => $file], 'Download token issued');
break;
case 'backup-update-settings':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
if (!validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
require_once RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
$patch = [];
if (isset($_POST['enabled'])) {
$patch['enabled'] = ($_POST['enabled'] === 'true' || $_POST['enabled'] === '1');
}
if (isset($_POST['storage_limit_mb'])) {
$patch['storage_limit_mb'] = max(0, (int)$_POST['storage_limit_mb']);
}
if (isset($_POST['cron_mode'])) {
$patch['cron_mode'] = $_POST['cron_mode'] === 'web' ? 'web' : 'server';
}
$retention = [];
foreach (['daily', 'weekly', 'monthly', 'yearly'] as $tier) {
$key = "retention_$tier";
if (isset($_POST[$key])) $retention[$tier] = max(0, (int)$_POST[$key]);
}
if (!empty($retention)) $patch['retention'] = $retention;
if (isset($_POST['remote_targets'])) {
$submitted = json_decode($_POST['remote_targets'], true);
if (!is_array($submitted)) {
jsonResponse(false, null, 'Invalid remote target settings');
}
$patch['remote_targets'] = backupRemoteMergeSubmittedTargets($submitted);
}
if (empty($patch)) {
jsonResponse(false, null, 'No settings to update');
}
if (!backupSaveConfig($patch)) {
jsonResponse(false, null, 'Could not write settings');
}
jsonResponse(true, backupStatus(), 'Backup settings updated');
break;
// (No backup-restore-from-pool — restore-site-backup now accepts
// either a file upload OR a `pool_file` parameter referencing a
// file in BACKUP_PATH. The dashboard uses the pool_file form.)
// ============================================================
// USER MANAGEMENT (admin only)
// ============================================================
}