false, 'retention' => [ 'daily' => 7, 'weekly' => 4, 'monthly' => 12, 'yearly' => 3, ], 'storage_limit_mb' => 2048, 'cron_mode' => 'server', 'web_cron_token' => '', 'remote_targets' => [], 'last_run' => null, 'last_status' => null, // 'success' | 'error' | null 'last_message' => null, ]; } /** Load and normalise the backup config from settings.json. */ function backupConfig() { $settingsPath = __DIR__ . '/../content/settings.json'; $settings = is_file($settingsPath) ? json_decode(file_get_contents($settingsPath), true) : []; $config = $settings['backup'] ?? []; $merged = array_replace_recursive(backupDefaults(), is_array($config) ? $config : []); // Normalise retention numeric values foreach (['daily', 'weekly', 'monthly', 'yearly'] as $tier) { $merged['retention'][$tier] = max(0, (int)($merged['retention'][$tier] ?? 0)); } $merged['storage_limit_mb'] = max(0, (int)($merged['storage_limit_mb'] ?? 0)); $merged['cron_mode'] = ($merged['cron_mode'] ?? 'server') === 'web' ? 'web' : 'server'; $merged['web_cron_token'] = is_string($merged['web_cron_token'] ?? null) ? $merged['web_cron_token'] : ''; $merged['remote_targets'] = backupNormalizeRemoteTargets($merged['remote_targets'] ?? []); return $merged; } function backupGenerateWebCronToken() { return bin2hex(random_bytes(32)); } function backupEnsureWebCronToken() { $config = backupConfig(); $token = trim((string)($config['web_cron_token'] ?? '')); if ($token !== '') { return $token; } $token = backupGenerateWebCronToken(); backupSaveConfig(['web_cron_token' => $token]); return $token; } function backupRemoteProviders() { $dropboxAppKey = defined('RAIJU_DROPBOX_APP_KEY') ? RAIJU_DROPBOX_APP_KEY : (getenv('RAIJU_DROPBOX_APP_KEY') ?: ''); $googleClientId = defined('RAIJU_GOOGLE_CLIENT_ID') ? RAIJU_GOOGLE_CLIENT_ID : (getenv('RAIJU_GOOGLE_CLIENT_ID') ?: ''); $onedriveClientId = defined('RAIJU_ONEDRIVE_CLIENT_ID') ? RAIJU_ONEDRIVE_CLIENT_ID : (getenv('RAIJU_ONEDRIVE_CLIENT_ID') ?: ''); $brokerUrl = backupRemoteOAuthBrokerUrl(); return [ 'dropbox' => [ 'label' => 'Dropbox', 'fields' => ['app_key', 'access_token', 'refresh_token', 'path'], 'secret_fields' => ['access_token', 'refresh_token'], 'has_global_oauth' => $dropboxAppKey !== '' || $brokerUrl !== '', ], 'google_drive' => [ 'label' => 'Google Drive', 'fields' => ['client_id', 'client_secret', 'access_token', 'refresh_token', 'folder_id'], 'secret_fields' => ['client_secret', 'access_token', 'refresh_token'], 'has_global_oauth' => $googleClientId !== '' || $brokerUrl !== '', ], 'onedrive' => [ 'label' => 'Microsoft OneDrive', 'fields' => ['client_id', 'client_secret', 'access_token', 'refresh_token', 'folder_path'], 'secret_fields' => ['client_secret', 'access_token', 'refresh_token'], 'has_global_oauth' => $onedriveClientId !== '' || $brokerUrl !== '', ], 'sftp' => [ 'label' => 'SFTP / SCP', 'fields' => ['host', 'port', 'username', 'password', 'remote_path'], 'secret_fields' => ['password'], ], 'ftp' => [ 'label' => 'FTP / FTPS', 'fields' => ['host', 'port', 'username', 'password', 'remote_path', 'ssl', 'passive'], 'secret_fields' => ['password'], ], 's3' => [ 'label' => 'S3-Compatible', 'fields' => ['endpoint', 'region', 'bucket', 'prefix', 'access_key', 'secret_key', 'path_style'], 'secret_fields' => ['secret_key'], ], 'webdav' => [ 'label' => 'WebDAV', 'fields' => ['url', 'username', 'password', 'bearer_token'], 'secret_fields' => ['password', 'bearer_token'], ], ]; } function backupRemoteOAuthBrokerUrl() { if (defined('RAIJU_AUTH_BROKER_URL')) { return rtrim((string)RAIJU_AUTH_BROKER_URL, '/'); } $env = getenv('RAIJU_AUTH_BROKER_URL'); if ($env !== false && $env !== '') { return rtrim($env, '/'); } return 'https://1studi.ru'; } function backupRemoteGlobalOAuthValue($type, $field) { $map = [ 'dropbox' => [ 'app_key' => 'RAIJU_DROPBOX_APP_KEY', ], 'google_drive' => [ 'client_id' => 'RAIJU_GOOGLE_CLIENT_ID', 'client_secret' => 'RAIJU_GOOGLE_CLIENT_SECRET', ], 'onedrive' => [ 'client_id' => 'RAIJU_ONEDRIVE_CLIENT_ID', 'client_secret' => 'RAIJU_ONEDRIVE_CLIENT_SECRET', ], ]; $constant = $map[$type][$field] ?? ''; if ($constant === '') return ''; if (defined($constant)) return constant($constant); return getenv($constant) ?: ''; } function backupNormalizeRemoteTargets($targets) { if (!is_array($targets)) return []; $providers = backupRemoteProviders(); $out = []; foreach ($targets as $target) { if (!is_array($target)) continue; $type = $target['type'] ?? ''; if (!isset($providers[$type])) continue; $id = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($target['id'] ?? '')); if ($id === '') $id = $type . '-' . substr(sha1(json_encode($target)), 0, 8); $settings = is_array($target['settings'] ?? null) ? $target['settings'] : []; $cleanSettings = []; foreach ($providers[$type]['fields'] as $field) { if (array_key_exists($field, $settings)) { $cleanSettings[$field] = is_bool($settings[$field]) ? $settings[$field] : trim((string)$settings[$field]); } } foreach (['expires_at', 'account_id', 'oauth_broker'] as $field) { if (array_key_exists($field, $settings)) { $cleanSettings[$field] = is_bool($settings[$field]) ? $settings[$field] : trim((string)$settings[$field]); } } $out[] = [ 'id' => $id, 'type' => $type, 'name' => trim((string)($target['name'] ?? $providers[$type]['label'])), 'enabled' => !empty($target['enabled']), 'settings' => $cleanSettings, 'last_upload' => $target['last_upload'] ?? null, 'last_status' => $target['last_status'] ?? null, 'last_message' => $target['last_message'] ?? null, 'last_file' => $target['last_file'] ?? null, ]; } return $out; } function backupRemotePublicTargets(array $targets) { $providers = backupRemoteProviders(); return array_map(function($target) use ($providers) { $secretFields = $providers[$target['type']]['secret_fields'] ?? []; $public = $target; foreach ($secretFields as $field) { if (!empty($public['settings'][$field])) { $public['settings'][$field] = '********'; } } return $public; }, $targets); } function backupRemoteMergeSubmittedTargets(array $submitted) { $current = backupConfig()['remote_targets'] ?? []; $byId = []; foreach ($current as $target) $byId[$target['id']] = $target; $providers = backupRemoteProviders(); $merged = []; foreach ($submitted as $target) { if (!is_array($target)) continue; $type = $target['type'] ?? ''; if (!isset($providers[$type])) continue; $id = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($target['id'] ?? '')); if ($id === '') $id = $type . '-' . substr(sha1(json_encode($target) . microtime(true)), 0, 8); $previous = $byId[$id] ?? null; $settings = is_array($target['settings'] ?? null) ? $target['settings'] : []; $cleanSettings = []; foreach ($providers[$type]['fields'] as $field) { $value = $settings[$field] ?? ''; $isSecret = in_array($field, $providers[$type]['secret_fields'], true); if ($isSecret && ($value === '' || $value === '********') && $previous) { $cleanSettings[$field] = $previous['settings'][$field] ?? ''; continue; } $isHiddenGlobalOAuthField = ( ($type === 'dropbox' && $field === 'app_key' && !empty($providers[$type]['has_global_oauth'])) || (($type === 'google_drive' || $type === 'onedrive') && ($field === 'client_id' || $field === 'client_secret') && !empty($providers[$type]['has_global_oauth'])) ); if ($isHiddenGlobalOAuthField && ($value === '' || $value === '********') && $previous) { $cleanSettings[$field] = $previous['settings'][$field] ?? ''; continue; } $cleanSettings[$field] = is_bool($value) ? $value : trim((string)$value); } foreach (['expires_at', 'account_id', 'oauth_broker'] as $field) { if ($previous && array_key_exists($field, $previous['settings'])) { $cleanSettings[$field] = $previous['settings'][$field]; } } $merged[] = [ 'id' => $id, 'type' => $type, 'name' => trim((string)($target['name'] ?? $providers[$type]['label'])), 'enabled' => !empty($target['enabled']), 'settings' => $cleanSettings, 'last_upload' => $previous['last_upload'] ?? null, 'last_status' => $previous['last_status'] ?? null, 'last_message' => $previous['last_message'] ?? null, 'last_file' => $previous['last_file'] ?? null, ]; } return backupNormalizeRemoteTargets($merged); } function backupScrubSettingsForArchive($settingsPath) { $settings = is_file($settingsPath) ? json_decode(file_get_contents($settingsPath), true) : null; if (!is_array($settings)) return file_get_contents($settingsPath); if (!empty($settings['backup']['remote_targets']) && is_array($settings['backup']['remote_targets'])) { $providers = backupRemoteProviders(); foreach ($settings['backup']['remote_targets'] as &$target) { $type = $target['type'] ?? ''; $secretFields = $providers[$type]['secret_fields'] ?? []; foreach ($secretFields as $field) { if (isset($target['settings'][$field]) && $target['settings'][$field] !== '') { $target['settings'][$field] = ''; } } } unset($target); } return json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } /** Return a stable site identifier for backup filenames and remote folders. */ function backupSiteIdentifier() { $raw = ''; if (defined('RAIJU_SITE_DOMAIN')) { $raw = (string)RAIJU_SITE_DOMAIN; } if ($raw === '') { $env = getenv('RAIJU_SITE_DOMAIN'); if ($env !== false) $raw = (string)$env; } if ($raw === '' && !empty($_SERVER['HTTP_HOST'])) { $raw = (string)$_SERVER['HTTP_HOST']; } if ($raw === '') { $siteRoot = realpath(__DIR__ . '/..') ?: dirname(__DIR__); $parent = basename(dirname($siteRoot)); $base = basename($siteRoot); $raw = strtolower($base) === 'httpdocs' && $parent !== '' ? $parent : $base; } $raw = strtolower(trim($raw)); $raw = preg_replace('#^https?://#', '', $raw); $raw = preg_replace('#/.*$#', '', $raw); $raw = preg_replace('/:\d+$/', '', $raw); $raw = preg_replace('/^www\./', '', $raw); $raw = preg_replace('/[^a-z0-9._-]+/', '-', $raw); $raw = trim($raw, '.-_'); return $raw !== '' ? $raw : 'raiju-site'; } function backupFilenamePattern() { return '/^(?!site-backup-)([a-z0-9][a-z0-9._-]*)-backup-(\d{4}-\d{2}-\d{2})_(\d{6})-(daily|weekly|monthly|yearly|manual)\.zip$/'; } function backupParseFilename($name) { if (!preg_match(backupFilenamePattern(), $name, $m)) return null; $date = $m[2]; $time = $m[3]; $tier = $m[4]; return [ 'site' => $m[1], 'date' => $date, 'time' => $time, 'tier' => $tier, 'mtime' => strtotime($date . ' ' . substr($time, 0, 2) . ':' . substr($time, 2, 2) . ':' . substr($time, 4, 2)), ]; } function backupIsPoolFilename($name) { return backupParseFilename($name) !== null; } function backupRemoteFolder($base) { $base = trim((string)$base); if ($base === '') $base = '/raiju Backups'; $site = backupSiteIdentifier(); $trimmed = trim($base, '/'); if ($trimmed === '') return '/' . $site; if (substr($trimmed, -strlen($site)) === $site) return '/' . $trimmed; return '/' . $trimmed . '/' . $site; } /** Shared lock for backup create/prune operations. */ function backupWithLock(callable $fn) { if (!defined('BACKUP_PATH')) { throw new BackupLockException('BACKUP_PATH not defined — run setup first.'); } if (!is_dir(BACKUP_PATH)) { @mkdir(BACKUP_PATH, 0755, true); } $lockPath = BACKUP_PATH . '.backup.lock'; $handle = @fopen($lockPath, 'c+'); if ($handle === false) { throw new BackupLockException('Could not open backup lock file.'); } if (!flock($handle, LOCK_EX | LOCK_NB)) { fclose($handle); throw new BackupLockException('Another backup run is already in progress.'); } ftruncate($handle, 0); fwrite($handle, (string)getmypid()); fflush($handle); try { return $fn(); } finally { flock($handle, LOCK_UN); fclose($handle); // Do not unlink: another process may already hold this lock inode. } } /** Directories that should never be included in full-site ZIP backups. */ function backupExcludeDirs() { return [ 'node_modules', '.git', 'screenshots', 'reference', '.codex-qa', '.vscode', '.idea', '.claude', 'tests', 'examples', 'vendor', 'website', 'raiju-alt', 'raiju-backup', ]; } /** File basenames that should never be included in full-site ZIP backups. */ function backupExcludeFiles() { return ['.DS_Store', 'Thumbs.db']; } /** Development and agent documentation at the project root. */ function backupExcludeRootFiles() { return [ 'AGENTS.md', 'AI-AGENT-GUIDE.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'SKILLS.md', 'architecture.md', 'design-qa.md', ]; } /** Return whether a relative path should be skipped in a full-site ZIP. */ function backupShouldSkipPath($relativePath) { $relativePath = str_replace('\\', '/', $relativePath); foreach (backupExcludeDirs() as $dir) { if ($relativePath === $dir || str_starts_with($relativePath, $dir . '/')) { return true; } } if (!str_contains($relativePath, '/') && in_array($relativePath, backupExcludeRootFiles(), true)) { return true; } $base = basename($relativePath); if (in_array($base, backupExcludeFiles(), true)) return true; if (str_ends_with($base, '.tmp') || str_ends_with($base, '.swp') || str_ends_with($base, '.lock') || str_starts_with($base, '.raiju-json-')) return true; // Short-lived probe files of the security check (includes/security-check.php). if (str_starts_with($base, 'raiju-access-check-')) return true; if (preg_match('#(^|/)\.restore-[^/]+(/|$)#', $relativePath)) return true; // Keep old JSON page backups, but never include generated backup ZIPs/logs. if (str_starts_with($relativePath, 'backups/')) { if (str_ends_with($base, '.zip') || $base === '.backup.lock' || $base === 'backup.log') { return true; } } return false; } /** Persist the backup config back to settings.json (merging with existing keys). */ function backupSaveConfig(array $patch) { $settingsPath = __DIR__ . '/../content/settings.json'; require_once __DIR__ . '/json-store.php'; return raijuJsonUpdate($settingsPath, static function (&$settings) use ($patch) { $settings['backup'] = array_replace_recursive($settings['backup'] ?? [], $patch); }); } /** * Determine which tier a new backup at $now should be tagged as. * Picks the highest tier that still has a free slot for $now's bucket * (year > month > week > day), so a single nightly run automatically * fills weekly/monthly/yearly slots when the current week/month/year * has no backup yet. */ function backupTierFor($now = null) { $now = $now ?: time(); $existing = backupListAll(); $year = (int)date('Y', $now); $hasYearly = false; $month = (int)date('Ym', $now); $hasMonthly = false; // Use ISO week so Monday-Sunday boundaries match common cron schedules. $week = (int)date('oW', $now); $hasWeekly = false; foreach ($existing as $b) { $t = $b['mtime']; if ($b['tier'] === 'yearly' && (int)date('Y', $t) === $year) $hasYearly = true; if ($b['tier'] === 'monthly' && (int)date('Ym', $t) === $month) $hasMonthly = true; if ($b['tier'] === 'weekly' && (int)date('oW', $t) === $week) $hasWeekly = true; } if (!$hasYearly) return 'yearly'; if (!$hasMonthly) return 'monthly'; if (!$hasWeekly) return 'weekly'; return 'daily'; } /** List all scheduled site backups, newest first. Manual ones included. */ function backupListAll() { if (!defined('BACKUP_PATH')) return []; backupMigrateShortLivedSiteBackupNames(); $files = glob(BACKUP_PATH . '*-backup-*.zip') ?: []; $out = []; foreach ($files as $path) { $name = basename($path); $parsed = backupParseFilename($name); if (!$parsed) continue; $out[] = [ 'file' => $name, 'tier' => $parsed['tier'], 'mtime' => $parsed['mtime'] ?: filemtime($path), 'size' => filesize($path) ?: 0, ]; } usort($out, fn($a, $b) => $b['mtime'] - $a['mtime']); return $out; } function backupMigrateShortLivedSiteBackupNames() { $files = glob(BACKUP_PATH . 'site-backup-*.zip') ?: []; if (empty($files)) return; $site = backupSiteIdentifier(); foreach ($files as $path) { $name = basename($path); if (!preg_match('/^site-backup-(\d{4}-\d{2}-\d{2})_(\d{6})-(daily|weekly|monthly|yearly|manual)\.zip$/', $name, $m)) { continue; } $newName = "{$site}-backup-{$m[1]}_{$m[2]}-{$m[3]}.zip"; $newPath = BACKUP_PATH . $newName; if (is_file($newPath)) continue; @rename($path, $newPath); } } /** Sum of all backup ZIP sizes in bytes. */ function backupTotalSize() { $total = 0; foreach (backupListAll() as $b) $total += $b['size']; return $total; } function backupRemoteRequireCurl() { if (!function_exists('curl_init')) { throw new RuntimeException('PHP cURL extension is required for this remote target.'); } } function backupRemoteCurl($url, array $options = []) { backupRemoteRequireCurl(); $ch = curl_init($url); $headers = $options['headers'] ?? []; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $options['timeout'] ?? 300, CURLOPT_CUSTOMREQUEST => $options['method'] ?? 'GET', CURLOPT_HTTPHEADER => $headers, ]); if (array_key_exists('body', $options)) { curl_setopt($ch, CURLOPT_POSTFIELDS, $options['body']); } $raw = curl_exec($ch); if ($raw === false) { $error = curl_error($ch); unset($ch); throw new RuntimeException($error); } $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $body = substr($raw, $headerSize); unset($ch); if ($status < 200 || $status >= 300) { $message = trim($body); if (strlen($message) > 240) $message = substr($message, 0, 240) . '...'; throw new RuntimeException("Remote returned HTTP $status" . ($message !== '' ? ": $message" : '.')); } return ['status' => $status, 'body' => $body]; } function backupRemoteUpload($filePath, array $target) { if (!is_file($filePath)) { return ['ok' => false, 'message' => 'Backup file not found.']; } try { switch ($target['type']) { case 'dropbox': backupRemoteUploadDropbox($filePath, $target); break; case 'google_drive': backupRemoteUploadGoogleDrive($filePath, $target); break; case 'onedrive': backupRemoteUploadOneDrive($filePath, $target); break; case 'sftp': backupRemoteUploadSftp($filePath, $target); break; case 'ftp': backupRemoteUploadFtp($filePath, $target); break; case 's3': backupRemoteUploadS3($filePath, $target); break; case 'webdav': backupRemoteUploadWebDav($filePath, $target); break; default: throw new RuntimeException('Unsupported remote target type.'); } return ['ok' => true, 'message' => 'Upload complete.']; } catch (Throwable $e) { return ['ok' => false, 'message' => $e->getMessage()]; } } function backupRemoteList(array $target) { try { switch ($target['type']) { case 'dropbox': return backupRemoteListDropbox($target); case 'google_drive': return backupRemoteListGoogleDrive($target); case 'onedrive': return backupRemoteListOneDrive($target); case 'ftp': return backupRemoteListFtp($target); default: throw new RuntimeException('Remote listing is not supported for this target type yet.'); } } catch (Throwable $e) { return ['ok' => false, 'message' => $e->getMessage(), 'files' => []]; } } function backupRemoteDownload($file, array $target, $destinationPath) { if (!backupIsPoolFilename($file)) { return ['ok' => false, 'message' => 'Invalid backup filename.']; } try { switch ($target['type']) { case 'dropbox': backupRemoteDownloadDropbox($file, $target, $destinationPath); break; case 'google_drive': backupRemoteDownloadGoogleDrive($file, $target, $destinationPath); break; case 'onedrive': backupRemoteDownloadOneDrive($file, $target, $destinationPath); break; case 'ftp': backupRemoteDownloadFtp($file, $target, $destinationPath); break; default: throw new RuntimeException('Remote download is not supported for this target type yet.'); } return ['ok' => true, 'message' => 'Downloaded.']; } catch (Throwable $e) { return ['ok' => false, 'message' => $e->getMessage()]; } } function backupRemoteDelete($file, array $target) { if (!backupIsPoolFilename($file)) { return ['ok' => false, 'message' => 'Invalid backup filename.']; } try { switch ($target['type']) { case 'dropbox': backupRemoteDeleteDropbox($file, $target); break; case 'google_drive': backupRemoteDeleteGoogleDrive($file, $target); break; case 'onedrive': backupRemoteDeleteOneDrive($file, $target); break; case 'ftp': backupRemoteDeleteFtp($file, $target); break; default: throw new RuntimeException('Remote delete is not supported for this target type yet.'); } return ['ok' => true, 'message' => 'Deleted.']; } catch (Throwable $e) { return ['ok' => false, 'message' => $e->getMessage()]; } } function backupRemoteRefreshTarget(array &$target) { $settings = $target['settings']; $refreshToken = $settings['refresh_token'] ?? ''; $expiresAt = (int)($settings['expires_at'] ?? 0); if (!empty($settings['access_token']) && $expiresAt > time() + 120) return; if ($refreshToken === '') return; if ($target['type'] === 'dropbox') { $clientId = $settings['app_key'] ?? ''; if ($clientId === '') $clientId = backupRemoteGlobalOAuthValue('dropbox', 'app_key'); $brokerUrl = backupRemoteOAuthBrokerUrl(); if (!empty($settings['oauth_broker']) || ($clientId === '' && $brokerUrl !== '')) { if ($brokerUrl === '') return; $response = backupRemoteCurl($brokerUrl . '/token/refresh', [ 'method' => 'POST', 'headers' => ['Content-Type: application/x-www-form-urlencoded'], 'body' => http_build_query([ 'provider' => 'dropbox', 'refresh_token' => $refreshToken, ]), 'timeout' => 60, ]); $brokerJson = json_decode($response['body'], true); if (!is_array($brokerJson) || empty($brokerJson['ok']) || empty($brokerJson['token']['access_token'])) { $message = is_array($brokerJson) ? ($brokerJson['message'] ?? 'Broker did not return an access token.') : 'Broker returned invalid JSON.'; throw new RuntimeException('OAuth token refresh failed: ' . $message); } $json = $brokerJson['token']; $target['settings']['access_token'] = $json['access_token']; if (!empty($json['refresh_token'])) { $target['settings']['refresh_token'] = $json['refresh_token']; } if (!empty($json['expires_in'])) { $target['settings']['expires_at'] = time() + (int)$json['expires_in']; } if (!empty($json['app_key'])) { $target['settings']['app_key'] = $json['app_key']; } $target['settings']['oauth_broker'] = $brokerUrl; return; } $tokenUrl = 'https://api.dropboxapi.com/oauth2/token'; $body = [ 'grant_type' => 'refresh_token', 'refresh_token' => $refreshToken, 'client_id' => $clientId, ]; } elseif ($target['type'] === 'google_drive') { $clientId = $settings['client_id'] ?? ''; if ($clientId === '') $clientId = backupRemoteGlobalOAuthValue('google_drive', 'client_id'); $brokerUrl = backupRemoteOAuthBrokerUrl(); if (!empty($settings['oauth_broker']) || ($clientId === '' && $brokerUrl !== '')) { if ($brokerUrl === '') return; $response = backupRemoteCurl($brokerUrl . '/token/refresh', [ 'method' => 'POST', 'headers' => ['Content-Type: application/x-www-form-urlencoded'], 'body' => http_build_query([ 'provider' => 'google_drive', 'refresh_token' => $refreshToken, ]), 'timeout' => 60, ]); $brokerJson = json_decode($response['body'], true); if (!is_array($brokerJson) || empty($brokerJson['ok']) || empty($brokerJson['token']['access_token'])) { $message = is_array($brokerJson) ? ($brokerJson['message'] ?? 'Broker did not return an access token.') : 'Broker returned invalid JSON.'; throw new RuntimeException('OAuth token refresh failed: ' . $message); } $json = $brokerJson['token']; $target['settings']['access_token'] = $json['access_token']; if (!empty($json['refresh_token'])) { $target['settings']['refresh_token'] = $json['refresh_token']; } if (!empty($json['expires_in'])) { $target['settings']['expires_at'] = time() + (int)$json['expires_in']; } if (!empty($json['client_id'])) { $target['settings']['client_id'] = $json['client_id']; } $target['settings']['oauth_broker'] = $brokerUrl; return; } $tokenUrl = 'https://oauth2.googleapis.com/token'; $body = [ 'grant_type' => 'refresh_token', 'refresh_token' => $refreshToken, 'client_id' => $clientId, ]; $clientSecret = $settings['client_secret'] ?? backupRemoteGlobalOAuthValue('google_drive', 'client_secret'); if ($clientSecret !== '') $body['client_secret'] = $clientSecret; } elseif ($target['type'] === 'onedrive') { $clientId = $settings['client_id'] ?? ''; if ($clientId === '') $clientId = backupRemoteGlobalOAuthValue('onedrive', 'client_id'); $brokerUrl = backupRemoteOAuthBrokerUrl(); if (!empty($settings['oauth_broker']) || ($clientId === '' && $brokerUrl !== '')) { if ($brokerUrl === '') return; $response = backupRemoteCurl($brokerUrl . '/token/refresh', [ 'method' => 'POST', 'headers' => ['Content-Type: application/x-www-form-urlencoded'], 'body' => http_build_query([ 'provider' => 'onedrive', 'refresh_token' => $refreshToken, ]), 'timeout' => 60, ]); $brokerJson = json_decode($response['body'], true); if (!is_array($brokerJson) || empty($brokerJson['ok']) || empty($brokerJson['token']['access_token'])) { $message = is_array($brokerJson) ? ($brokerJson['message'] ?? 'Broker did not return an access token.') : 'Broker returned invalid JSON.'; throw new RuntimeException('OAuth token refresh failed: ' . $message); } $json = $brokerJson['token']; $target['settings']['access_token'] = $json['access_token']; if (!empty($json['refresh_token'])) { $target['settings']['refresh_token'] = $json['refresh_token']; } if (!empty($json['expires_in'])) { $target['settings']['expires_at'] = time() + (int)$json['expires_in']; } if (!empty($json['client_id'])) { $target['settings']['client_id'] = $json['client_id']; } $target['settings']['oauth_broker'] = $brokerUrl; return; } $tokenUrl = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; $body = [ 'grant_type' => 'refresh_token', 'refresh_token' => $refreshToken, 'client_id' => $clientId, 'scope' => 'offline_access Files.ReadWrite.AppFolder', ]; $clientSecret = $settings['client_secret'] ?? backupRemoteGlobalOAuthValue('onedrive', 'client_secret'); if ($clientSecret !== '') $body['client_secret'] = $clientSecret; } else { return; } if ($clientId === '') return; $response = backupRemoteCurl($tokenUrl, [ 'method' => 'POST', 'headers' => ['Content-Type: application/x-www-form-urlencoded'], 'body' => http_build_query($body), 'timeout' => 60, ]); $json = json_decode($response['body'], true); if (!is_array($json) || empty($json['access_token'])) { throw new RuntimeException('OAuth token refresh failed: provider did not return an access token.'); } $target['settings']['access_token'] = $json['access_token']; if (!empty($json['refresh_token'])) { $target['settings']['refresh_token'] = $json['refresh_token']; } if (!empty($json['expires_in'])) { $target['settings']['expires_at'] = time() + (int)$json['expires_in']; } } function backupRemoteUploadDropbox($filePath, array $target) { $settings = $target['settings']; $token = $settings['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Dropbox access token is missing.'); $folder = backupRemoteFolder($settings['path'] ?? '/raiju Backups'); backupRemoteEnsureDropboxFolder($token, $folder); $remotePath = ($folder === '/' ? '' : $folder) . '/' . basename($filePath); backupRemoteCurl('https://content.dropboxapi.com/2/files/upload', [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/octet-stream', 'Dropbox-API-Arg: ' . json_encode([ 'path' => $remotePath, 'mode' => 'overwrite', 'autorename' => false, 'mute' => false, 'strict_conflict' => false, ], JSON_UNESCAPED_SLASHES), ], 'body' => file_get_contents($filePath), ]); } function backupRemoteEnsureDropboxFolder($token, $folder) { $folder = '/' . trim($folder, '/'); if ($folder === '/') return; $parts = array_values(array_filter(explode('/', trim($folder, '/')))); $path = ''; foreach ($parts as $part) { $path .= '/' . $part; try { backupRemoteCurl('https://api.dropboxapi.com/2/files/create_folder_v2', [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json', ], 'body' => json_encode(['path' => $path, 'autorename' => false], JSON_UNESCAPED_SLASHES), ]); } catch (RuntimeException $e) { if (stripos($e->getMessage(), 'conflict') === false) { throw $e; } } } } function backupRemoteDropboxPath(array $target, $file = '') { $settings = $target['settings']; $folder = backupRemoteFolder($settings['path'] ?? '/raiju Backups'); return ($folder === '/' ? '' : $folder) . ($file !== '' ? '/' . basename($file) : ''); } function backupRemoteListDropbox(array $target) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Dropbox access token is missing.'); $folder = backupRemoteDropboxPath($target); $files = []; $cursor = null; do { $url = $cursor === null ? 'https://api.dropboxapi.com/2/files/list_folder' : 'https://api.dropboxapi.com/2/files/list_folder/continue'; $body = $cursor === null ? ['path' => $folder === '/' ? '' : $folder, 'recursive' => false, 'include_deleted' => false] : ['cursor' => $cursor]; try { $result = backupRemoteCurl($url, [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json', ], 'body' => json_encode($body, JSON_UNESCAPED_SLASHES), ]); } catch (RuntimeException $e) { if (stripos($e->getMessage(), 'path/not_found') !== false) { return ['ok' => true, 'message' => 'No remote backups found.', 'files' => []]; } throw $e; } $json = json_decode($result['body'] ?? '', true); foreach (($json['entries'] ?? []) as $entry) { if (($entry['.tag'] ?? '') !== 'file') continue; $name = $entry['name'] ?? ''; if (!backupIsPoolFilename($name)) continue; $parsed = backupParseFilename($name); $files[] = [ 'file' => $name, 'tier' => $parsed['tier'], 'mtime' => !empty($entry['client_modified']) ? strtotime($entry['client_modified']) : ($parsed['mtime'] ?: 0), 'size' => (int)($entry['size'] ?? 0), ]; } $cursor = !empty($json['has_more']) ? ($json['cursor'] ?? null) : null; } while ($cursor !== null); usort($files, fn($a, $b) => $b['mtime'] - $a['mtime']); return ['ok' => true, 'message' => count($files) . ' remote backup(s).', 'files' => $files]; } function backupRemoteDownloadDropbox($file, array $target, $destinationPath) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Dropbox access token is missing.'); $remotePath = backupRemoteDropboxPath($target, $file); $result = backupRemoteCurl('https://content.dropboxapi.com/2/files/download', [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Dropbox-API-Arg: ' . json_encode(['path' => $remotePath], JSON_UNESCAPED_SLASHES), ], ]); if (file_put_contents($destinationPath, $result['body']) === false) { throw new RuntimeException('Could not write downloaded backup.'); } } function backupRemoteDeleteDropbox($file, array $target) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Dropbox access token is missing.'); $remotePath = backupRemoteDropboxPath($target, $file); backupRemoteCurl('https://api.dropboxapi.com/2/files/delete_v2', [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json', ], 'body' => json_encode(['path' => $remotePath], JSON_UNESCAPED_SLASHES), ]); } function backupRemoteUploadGoogleDrive($filePath, array $target) { $settings = $target['settings']; $token = $settings['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Google Drive access token is missing.'); $metadata = ['name' => basename($filePath)]; $siteFolderId = backupRemoteGoogleFolderPathId($token, $settings['folder_id'] ?? '', $settings['folder_path'] ?? '/raiju Backups'); if ($siteFolderId !== '') $metadata['parents'] = [$siteFolderId]; $boundary = 'raiju-' . bin2hex(random_bytes(8)); $body = "--$boundary\r\n" . "Content-Type: application/json; charset=UTF-8\r\n\r\n" . json_encode($metadata, JSON_UNESCAPED_SLASHES) . "\r\n" . "--$boundary\r\n" . "Content-Type: application/zip\r\n\r\n" . file_get_contents($filePath) . "\r\n" . "--$boundary--"; backupRemoteCurl('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: multipart/related; boundary=' . $boundary, ], 'body' => $body, ]); } function backupRemoteGoogleFolderPathId($token, $parentId = '', $basePath = '/raiju Backups') { $folder = trim(backupRemoteFolder($basePath), '/'); if ($folder === '') return ''; $currentParent = trim((string)$parentId); foreach (array_values(array_filter(explode('/', $folder))) as $part) { $currentParent = backupRemoteGoogleFolderId($token, $part, $currentParent); } return $currentParent; } function backupRemoteGoogleFolderId($token, $name, $parentId = '') { $safeName = str_replace(["\\", "'"], ["\\\\", "\\'"], (string)$name); $parentId = trim((string)$parentId); $parentClause = $parentId !== '' ? "'" . str_replace("'", "\\'", $parentId) . "' in parents" : "'root' in parents"; $query = "mimeType='application/vnd.google-apps.folder' and name='{$safeName}' and trashed=false and {$parentClause}"; $list = backupRemoteCurl('https://www.googleapis.com/drive/v3/files?' . http_build_query([ 'q' => $query, 'fields' => 'files(id,name)', 'pageSize' => 1, ]), [ 'headers' => ['Authorization: Bearer ' . $token], ]); $json = json_decode($list['body'] ?? '', true); if (!empty($json['files'][0]['id'])) return $json['files'][0]['id']; $metadata = [ 'name' => $name, 'mimeType' => 'application/vnd.google-apps.folder', ]; if ($parentId !== '') $metadata['parents'] = [$parentId]; $created = backupRemoteCurl('https://www.googleapis.com/drive/v3/files?fields=id', [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json; charset=UTF-8', ], 'body' => json_encode($metadata, JSON_UNESCAPED_SLASHES), ]); $createdJson = json_decode($created['body'] ?? '', true); return $createdJson['id'] ?? ''; } function backupRemoteGoogleSiteFolderId($token, $parentId = '') { $site = backupSiteIdentifier(); $safeName = str_replace(["\\", "'"], ["\\\\", "\\'"], $site); $parentId = trim((string)$parentId); $parentClause = $parentId !== '' ? "'" . str_replace("'", "\\'", $parentId) . "' in parents" : "'root' in parents"; $query = "mimeType='application/vnd.google-apps.folder' and name='{$safeName}' and trashed=false and {$parentClause}"; $list = backupRemoteCurl('https://www.googleapis.com/drive/v3/files?' . http_build_query([ 'q' => $query, 'fields' => 'files(id,name)', 'pageSize' => 1, ]), [ 'headers' => ['Authorization: Bearer ' . $token], ]); $json = json_decode($list['body'] ?? '', true); if (!empty($json['files'][0]['id'])) return $json['files'][0]['id']; $metadata = [ 'name' => $site, 'mimeType' => 'application/vnd.google-apps.folder', ]; if ($parentId !== '') $metadata['parents'] = [$parentId]; $created = backupRemoteCurl('https://www.googleapis.com/drive/v3/files?fields=id', [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json; charset=UTF-8', ], 'body' => json_encode($metadata, JSON_UNESCAPED_SLASHES), ]); $createdJson = json_decode($created['body'] ?? '', true); return $createdJson['id'] ?? ''; } function backupRemoteGoogleFileId($file, array $target) { $settings = $target['settings']; $token = $settings['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Google Drive access token is missing.'); $folderId = backupRemoteGoogleFolderPathId($token, $settings['folder_id'] ?? '', $settings['folder_path'] ?? '/raiju Backups'); $safeName = str_replace(["\\", "'"], ["\\\\", "\\'"], basename($file)); $query = "mimeType!='application/vnd.google-apps.folder' and name='{$safeName}' and trashed=false"; if ($folderId !== '') { $query .= " and '" . str_replace("'", "\\'", $folderId) . "' in parents"; } $result = backupRemoteCurl('https://www.googleapis.com/drive/v3/files?' . http_build_query([ 'q' => $query, 'fields' => 'files(id,name,size,modifiedTime)', 'pageSize' => 1, ]), [ 'headers' => ['Authorization: Bearer ' . $token], ]); $json = json_decode($result['body'] ?? '', true); return $json['files'][0] ?? null; } function backupRemoteListGoogleDrive(array $target) { $settings = $target['settings']; $token = $settings['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Google Drive access token is missing.'); $folderId = backupRemoteGoogleFolderPathId($token, $settings['folder_id'] ?? '', $settings['folder_path'] ?? '/raiju Backups'); if ($folderId === '') return ['ok' => true, 'message' => 'No remote backups found.', 'files' => []]; $files = []; $pageToken = null; do { $params = [ 'q' => "'" . str_replace("'", "\\'", $folderId) . "' in parents and trashed=false", 'fields' => 'nextPageToken,files(id,name,size,modifiedTime)', 'pageSize' => 100, 'orderBy' => 'modifiedTime desc', ]; if ($pageToken) $params['pageToken'] = $pageToken; $result = backupRemoteCurl('https://www.googleapis.com/drive/v3/files?' . http_build_query($params), [ 'headers' => ['Authorization: Bearer ' . $token], ]); $json = json_decode($result['body'] ?? '', true); foreach (($json['files'] ?? []) as $entry) { $name = $entry['name'] ?? ''; if (!backupIsPoolFilename($name)) continue; $parsed = backupParseFilename($name); $files[] = [ 'file' => $name, 'tier' => $parsed['tier'], 'mtime' => !empty($entry['modifiedTime']) ? strtotime($entry['modifiedTime']) : ($parsed['mtime'] ?: 0), 'size' => (int)($entry['size'] ?? 0), ]; } $pageToken = $json['nextPageToken'] ?? null; } while ($pageToken); usort($files, fn($a, $b) => $b['mtime'] - $a['mtime']); return ['ok' => true, 'message' => count($files) . ' remote backup(s).', 'files' => $files]; } function backupRemoteDownloadGoogleDrive($file, array $target, $destinationPath) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Google Drive access token is missing.'); $entry = backupRemoteGoogleFileId($file, $target); if (!$entry || empty($entry['id'])) throw new RuntimeException('Google Drive backup file not found.'); $result = backupRemoteCurl('https://www.googleapis.com/drive/v3/files/' . rawurlencode($entry['id']) . '?alt=media', [ 'headers' => ['Authorization: Bearer ' . $token], ]); if (file_put_contents($destinationPath, $result['body']) === false) { throw new RuntimeException('Could not write downloaded backup.'); } } function backupRemoteDeleteGoogleDrive($file, array $target) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('Google Drive access token is missing.'); $entry = backupRemoteGoogleFileId($file, $target); if (!$entry || empty($entry['id'])) return; backupRemoteCurl('https://www.googleapis.com/drive/v3/files/' . rawurlencode($entry['id']), [ 'method' => 'DELETE', 'headers' => ['Authorization: Bearer ' . $token], ]); } function backupRemoteUploadOneDrive($filePath, array $target) { $settings = $target['settings']; $token = $settings['access_token'] ?? ''; if ($token === '') throw new RuntimeException('OneDrive access token is missing.'); $folder = trim(backupRemoteFolder($settings['folder_path'] ?? '/raiju Backups'), '/'); backupRemoteEnsureOneDriveFolder($token, $folder); $remotePath = rawurlencode(($folder !== '' ? $folder . '/' : '') . basename($filePath)); $remotePath = str_replace('%2F', '/', $remotePath); backupRemoteCurl('https://graph.microsoft.com/v1.0/me/drive/special/approot:/' . $remotePath . ':/content', [ 'method' => 'PUT', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/zip', ], 'body' => file_get_contents($filePath), ]); } function backupRemoteOneDriveItemUrl($path = '') { $path = trim((string)$path, '/'); if ($path === '') { return 'https://graph.microsoft.com/v1.0/me/drive/special/approot'; } $encoded = str_replace('%2F', '/', rawurlencode($path)); return 'https://graph.microsoft.com/v1.0/me/drive/special/approot:/' . $encoded . ':'; } function backupRemoteEnsureOneDriveFolder($token, $folder) { $parts = array_values(array_filter(explode('/', trim((string)$folder, '/')))); $path = ''; foreach ($parts as $part) { $endpoint = $path === '' ? 'https://graph.microsoft.com/v1.0/me/drive/special/approot/children' : backupRemoteOneDriveItemUrl($path) . '/children'; try { backupRemoteCurl($endpoint, [ 'method' => 'POST', 'headers' => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json', ], 'body' => json_encode([ 'name' => $part, 'folder' => new stdClass(), '@microsoft.graph.conflictBehavior' => 'fail', ], JSON_UNESCAPED_SLASHES), ]); } catch (RuntimeException $e) { $message = $e->getMessage(); if (stripos($message, 'nameAlreadyExists') === false && stripos($message, 'conflict') === false) { throw $e; } } $path = $path === '' ? $part : $path . '/' . $part; } } function backupRemoteOneDriveFolderPath(array $target) { $settings = $target['settings']; return trim(backupRemoteFolder($settings['folder_path'] ?? '/raiju Backups'), '/'); } function backupRemoteListOneDrive(array $target) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('OneDrive access token is missing.'); $folder = backupRemoteOneDriveFolderPath($target); backupRemoteEnsureOneDriveFolder($token, $folder); $url = backupRemoteOneDriveItemUrl($folder) . '/children?$select=name,size,lastModifiedDateTime,file&$top=200'; $files = []; do { $result = backupRemoteCurl($url, [ 'headers' => ['Authorization: Bearer ' . $token], ]); $json = json_decode($result['body'] ?? '', true); foreach (($json['value'] ?? []) as $entry) { if (empty($entry['file'])) continue; $name = $entry['name'] ?? ''; if (!backupIsPoolFilename($name)) continue; $parsed = backupParseFilename($name); $files[] = [ 'file' => $name, 'tier' => $parsed['tier'], 'mtime' => !empty($entry['lastModifiedDateTime']) ? strtotime($entry['lastModifiedDateTime']) : ($parsed['mtime'] ?: 0), 'size' => (int)($entry['size'] ?? 0), ]; } $url = $json['@odata.nextLink'] ?? ''; } while ($url !== ''); usort($files, fn($a, $b) => $b['mtime'] - $a['mtime']); return ['ok' => true, 'message' => count($files) . ' remote backup(s).', 'files' => $files]; } function backupRemoteDownloadOneDrive($file, array $target, $destinationPath) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('OneDrive access token is missing.'); $folder = backupRemoteOneDriveFolderPath($target); $remotePath = ($folder !== '' ? $folder . '/' : '') . basename($file); $result = backupRemoteCurl(backupRemoteOneDriveItemUrl($remotePath) . '/content', [ 'headers' => ['Authorization: Bearer ' . $token], ]); if (file_put_contents($destinationPath, $result['body']) === false) { throw new RuntimeException('Could not write downloaded backup.'); } } function backupRemoteDeleteOneDrive($file, array $target) { $token = $target['settings']['access_token'] ?? ''; if ($token === '') throw new RuntimeException('OneDrive access token is missing.'); $folder = backupRemoteOneDriveFolderPath($target); $remotePath = ($folder !== '' ? $folder . '/' : '') . basename($file); backupRemoteCurl(backupRemoteOneDriveItemUrl($remotePath), [ 'method' => 'DELETE', 'headers' => ['Authorization: Bearer ' . $token], ]); } function backupRemoteUploadSftp($filePath, array $target) { if (!function_exists('ssh2_connect')) { throw new RuntimeException('PHP ssh2 extension is required for SFTP/SCP uploads.'); } $settings = $target['settings']; $host = $settings['host'] ?? ''; $user = $settings['username'] ?? ''; $pass = $settings['password'] ?? ''; $port = max(1, (int)($settings['port'] ?? 22)); $remoteDir = rtrim($settings['remote_path'] ?? '', '/'); if ($host === '' || $user === '' || $remoteDir === '') { throw new RuntimeException('SFTP host, username, and remote path are required.'); } $remoteDir .= '/' . backupSiteIdentifier(); $conn = @ssh2_connect($host, $port); if (!$conn) throw new RuntimeException('Could not connect to SFTP server.'); if (!@ssh2_auth_password($conn, $user, $pass)) { throw new RuntimeException('SFTP authentication failed.'); } $sftp = @ssh2_sftp($conn); if ($sftp) { @ssh2_sftp_mkdir($sftp, $remoteDir, 0755, true); } $remoteFile = $remoteDir . '/' . basename($filePath); if (!@ssh2_scp_send($conn, $filePath, $remoteFile, 0644)) { throw new RuntimeException('SFTP upload failed.'); } } function backupRemoteFtpConnect(array $target) { if (!function_exists('ftp_connect')) { throw new RuntimeException('PHP FTP extension is required for FTP/FTPS uploads.'); } $settings = $target['settings']; $host = trim((string)($settings['host'] ?? '')); $user = (string)($settings['username'] ?? ''); $pass = (string)($settings['password'] ?? ''); $ssl = !empty($settings['ssl']); $port = max(1, (int)($settings['port'] ?? 21)); if ($host === '' || $user === '') { throw new RuntimeException('FTP host and username are required.'); } if ($ssl && !function_exists('ftp_ssl_connect')) { throw new RuntimeException('PHP FTP SSL support is required for FTPS.'); } $conn = $ssl ? @ftp_ssl_connect($host, $port, 20) : @ftp_connect($host, $port, 20); if (!$conn) { throw new RuntimeException('Could not connect to FTP server.'); } if (!@ftp_login($conn, $user, $pass)) { @ftp_close($conn); throw new RuntimeException('FTP authentication failed.'); } $passive = array_key_exists('passive', $settings) ? !empty($settings['passive']) : true; @ftp_pasv($conn, $passive); return $conn; } function backupRemoteFtpDir(array $target) { $settings = $target['settings']; $base = rtrim(trim((string)($settings['remote_path'] ?? '')), '/'); $site = backupSiteIdentifier(); if ($base === '') return $site; if ($base === '/') return '/' . $site; return $base . '/' . $site; } function backupRemoteFtpEnsureDir($conn, $dir) { $absolute = str_starts_with($dir, '/'); $parts = array_values(array_filter(explode('/', trim($dir, '/')), fn($part) => $part !== '')); if ($absolute) { @ftp_chdir($conn, '/'); } foreach ($parts as $part) { if (@ftp_chdir($conn, $part)) { continue; } if (!@ftp_mkdir($conn, $part) || !@ftp_chdir($conn, $part)) { throw new RuntimeException('Could not create FTP remote directory.'); } } } function backupRemoteFtpOpenDir(array $target, $create = false) { $conn = backupRemoteFtpConnect($target); $dir = backupRemoteFtpDir($target); if ($create) { backupRemoteFtpEnsureDir($conn, $dir); return $conn; } if (!@ftp_chdir($conn, $dir)) { @ftp_close($conn); return null; } return $conn; } function backupRemoteUploadFtp($filePath, array $target) { $conn = backupRemoteFtpOpenDir($target, true); $remoteFile = basename($filePath); if (!@ftp_put($conn, $remoteFile, $filePath, FTP_BINARY)) { @ftp_close($conn); throw new RuntimeException('FTP upload failed.'); } @ftp_close($conn); } function backupRemoteListFtp(array $target) { $conn = backupRemoteFtpOpenDir($target, false); if (!$conn) { return ['ok' => true, 'message' => 'No remote backups found.', 'files' => []]; } $names = @ftp_nlist($conn, '.') ?: []; $files = []; foreach ($names as $name) { $base = basename($name); if (!backupIsPoolFilename($base)) continue; $parsed = backupParseFilename($base); $mtime = @ftp_mdtm($conn, $base); $size = @ftp_size($conn, $base); $files[] = [ 'file' => $base, 'tier' => $parsed['tier'], 'mtime' => $mtime > 0 ? $mtime : ($parsed['mtime'] ?: 0), 'size' => $size > 0 ? $size : 0, ]; } @ftp_close($conn); usort($files, fn($a, $b) => $b['mtime'] - $a['mtime']); return ['ok' => true, 'message' => count($files) . ' remote backup(s).', 'files' => $files]; } function backupRemoteDownloadFtp($file, array $target, $destinationPath) { $conn = backupRemoteFtpOpenDir($target, false); if (!$conn) throw new RuntimeException('FTP remote directory not found.'); if (!@ftp_get($conn, $destinationPath, basename($file), FTP_BINARY)) { @ftp_close($conn); throw new RuntimeException('FTP download failed.'); } @ftp_close($conn); } function backupRemoteDeleteFtp($file, array $target) { $conn = backupRemoteFtpOpenDir($target, false); if (!$conn) throw new RuntimeException('FTP remote directory not found.'); if (!@ftp_delete($conn, basename($file))) { @ftp_close($conn); throw new RuntimeException('FTP delete failed.'); } @ftp_close($conn); } function backupRemoteUploadWebDav($filePath, array $target) { $settings = $target['settings']; $baseUrl = rtrim($settings['url'] ?? '', '/'); if ($baseUrl === '') throw new RuntimeException('WebDAV URL is missing.'); $baseUrl .= '/' . rawurlencode(backupSiteIdentifier()); $headers = ['Content-Type: application/zip']; if (!empty($settings['bearer_token'])) { $headers[] = 'Authorization: Bearer ' . $settings['bearer_token']; } elseif (!empty($settings['username']) || !empty($settings['password'])) { $headers[] = 'Authorization: Basic ' . base64_encode(($settings['username'] ?? '') . ':' . ($settings['password'] ?? '')); } backupRemoteCurl($baseUrl . '/' . rawurlencode(basename($filePath)), [ 'method' => 'PUT', 'headers' => $headers, 'body' => file_get_contents($filePath), ]); } function backupRemoteUploadS3($filePath, array $target) { $settings = $target['settings']; $accessKey = $settings['access_key'] ?? ''; $secretKey = $settings['secret_key'] ?? ''; $bucket = $settings['bucket'] ?? ''; $region = $settings['region'] ?? 'us-east-1'; $endpoint = rtrim($settings['endpoint'] ?? '', '/'); $prefix = trim($settings['prefix'] ?? '', '/'); if ($accessKey === '' || $secretKey === '' || $bucket === '') { throw new RuntimeException('S3 access key, secret key, and bucket are required.'); } if ($endpoint === '') $endpoint = 'https://s3.' . $region . '.amazonaws.com'; $pathStyle = !empty($settings['path_style']); $sitePrefix = ($prefix !== '' ? $prefix . '/' : '') . backupSiteIdentifier(); $key = $sitePrefix . '/' . basename($filePath); $host = parse_url($endpoint, PHP_URL_HOST); if (!$host) throw new RuntimeException('Invalid S3 endpoint.'); $scheme = parse_url($endpoint, PHP_URL_SCHEME) ?: 'https'; $basePath = rtrim(parse_url($endpoint, PHP_URL_PATH) ?: '', '/'); if ($pathStyle) { $uri = $basePath . '/' . rawurlencode($bucket) . '/' . str_replace('%2F', '/', rawurlencode($key)); $url = $scheme . '://' . $host . $uri; } else { $uri = $basePath . '/' . str_replace('%2F', '/', rawurlencode($key)); $url = $scheme . '://' . $bucket . '.' . $host . $uri; $host = $bucket . '.' . $host; } $body = file_get_contents($filePath); $payloadHash = hash('sha256', $body); $now = gmdate('Ymd\THis\Z'); $date = substr($now, 0, 8); $scope = "$date/$region/s3/aws4_request"; $headers = [ 'host' => $host, 'x-amz-content-sha256' => $payloadHash, 'x-amz-date' => $now, ]; ksort($headers); $canonicalHeaders = ''; foreach ($headers as $k => $v) $canonicalHeaders .= strtolower($k) . ':' . trim($v) . "\n"; $signedHeaders = implode(';', array_keys($headers)); $canonicalRequest = "PUT\n$uri\n\n$canonicalHeaders\n$signedHeaders\n$payloadHash"; $stringToSign = "AWS4-HMAC-SHA256\n$now\n$scope\n" . hash('sha256', $canonicalRequest); $kDate = hash_hmac('sha256', $date, 'AWS4' . $secretKey, true); $kRegion = hash_hmac('sha256', $region, $kDate, true); $kService = hash_hmac('sha256', 's3', $kRegion, true); $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); $signature = hash_hmac('sha256', $stringToSign, $kSigning); $auth = "AWS4-HMAC-SHA256 Credential=$accessKey/$scope, SignedHeaders=$signedHeaders, Signature=$signature"; backupRemoteCurl($url, [ 'method' => 'PUT', 'headers' => [ 'Host: ' . $host, 'Authorization: ' . $auth, 'x-amz-content-sha256: ' . $payloadHash, 'x-amz-date: ' . $now, 'Content-Type: application/zip', ], 'body' => $body, ]); } function backupUploadRemoteTargets($file, $targetId = null) { $config = backupConfig(); $path = BACKUP_PATH . $file; $targets = array_filter($config['remote_targets'], function($target) use ($targetId) { if ($targetId !== null && $target['id'] !== $targetId) return false; return $targetId !== null || !empty($target['enabled']); }); if ($targetId !== null && empty($targets)) { return [[ 'id' => $targetId, 'name' => $targetId, 'type' => 'unknown', 'ok' => false, 'message' => 'Remote target not found.', ]]; } $results = []; foreach ($targets as $target) { try { backupRemoteRefreshTarget($target); $result = backupRemoteUpload($path, $target); if (!empty($result['ok'])) { try { $pruned = backupPruneRemoteTarget($target); if ($pruned > 0) { $result['message'] .= " Pruned {$pruned} remote backup(s)."; } } catch (Throwable $e) { $result['message'] .= " Remote pruning skipped: " . $e->getMessage(); } } } catch (Throwable $e) { $result = ['ok' => false, 'message' => $e->getMessage()]; } $target['last_upload'] = date('c'); $target['last_status'] = $result['ok'] ? 'success' : 'error'; $target['last_message'] = $result['message']; $target['last_file'] = $file; $results[] = [ 'id' => $target['id'], 'name' => $target['name'], 'type' => $target['type'], 'ok' => $result['ok'], 'message' => $result['message'], ]; foreach ($config['remote_targets'] as $idx => $existing) { if ($existing['id'] === $target['id']) { $config['remote_targets'][$idx] = $target; break; } } } backupSaveConfig(['remote_targets' => $config['remote_targets']]); return $results; } function backupPruneRemoteTarget(array $target) { $listed = backupRemoteList($target); if (empty($listed['ok'])) return 0; $localFiles = []; foreach (backupListAll() as $backup) { $localFiles[$backup['file']] = true; } $deleted = 0; foreach ($listed['files'] as $remoteFile) { $file = $remoteFile['file'] ?? ''; if ($file === '' || isset($localFiles[$file])) continue; $result = backupRemoteDelete($file, $target); if (!empty($result['ok'])) $deleted++; } return $deleted; } function backupRemoteTargetById($targetId) { $config = backupConfig(); foreach ($config['remote_targets'] as $target) { if ($target['id'] === $targetId) return $target; } return null; } function backupSaveRemoteTarget(array $target) { $config = backupConfig(); foreach ($config['remote_targets'] as $idx => $existing) { if ($existing['id'] === $target['id']) { $config['remote_targets'][$idx] = $target; backupSaveConfig(['remote_targets' => $config['remote_targets']]); return true; } } return false; } /** * Files that identify a complete production Raiju archive. * router.php is intentionally not required because it is only used by PHP's * built-in development server and may be excluded from production deploys. */ function backupRestoreRequiredFiles(): array { return [ 'admin/api.php', 'admin/dashboard.php', 'admin/config.php', 'includes/content-loader.php', 'includes/header.php', 'includes/footer.php', 'route.php', 'index.php', 'css/style.css', ]; } /** Site media formats must survive the same backup/restore round trip. */ function backupRestoreAllowedExtensions(): array { return [ 'php', 'json', 'css', 'js', 'html', 'htm', 'htaccess', 'jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'ico', 'avif', 'mp3', 'ogg', 'wav', 'm4a', 'aac', 'flac', 'mp4', 'webm', 'mov', 'm4v', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'txt', 'xml', 'md', ]; } function backupRestoreIsLanguageEntry(string $entry): bool { return !str_starts_with($entry, 'js/') && (bool)preg_match('#^[a-z]{2}/#', $entry); } /** * Restrict executable PHP files to Raiju core and documented site-owned * template locations. */ function backupRestorePhpEntryAllowed(string $entry): bool { $allowedRootPhp = [ 'index.php', 'router.php', 'route.php', '404.php', '410.php', 'error.php', 'sitemap.php', ]; if (!str_contains($entry, '/') && in_array($entry, $allowedRootPhp, true)) { return true; } foreach (['admin/', 'includes/', 'api/', 'cli/', 'examples/', 'templates/'] as $dir) { if (str_starts_with($entry, $dir)) { return true; } } return backupRestoreIsLanguageEntry($entry); } /** * Development-only archive entries may occur in backups created by older * versions. They are accepted for backwards compatibility, but are never * restored into the public site tree. */ function backupRestoreEntryIgnored(string $entry): bool { $entry = str_replace('\\', '/', ltrim($entry, '/')); return $entry === 'tests' || str_starts_with($entry, 'tests/') || str_ends_with($entry, '.lock') || preg_match('#(^|/)\.(restore-|raiju-json-)#', $entry); } /** * Site-owned files restored by the "content only" mode. */ function backupRestoreContentEntryAllowed(string $entry): bool { if (str_starts_with($entry, 'content/') || str_starts_with($entry, 'assets/') || str_starts_with($entry, 'templates/') || backupRestoreIsLanguageEntry($entry)) { return true; } if (in_array($entry, [ 'includes/header.php', 'includes/footer.php', 'includes/nav-config.php', 'includes/site-page-hook.php', 'css/fonts.css', 'css/website.css', 'js/main.js', ], true)) { return true; } if (preg_match('#^css/page-[^/]+\.css$#i', $entry)) { return true; } return str_starts_with($entry, 'backups/') && str_ends_with($entry, '.json'); } /** * Build a fresh full-site ZIP and write it to BACKUP_PATH. * Returns ['ok' => bool, 'file' => ..., 'size' => ..., 'message' => ...]. */ function backupCreate($tier = null, $now = null) { if (!class_exists('ZipArchive')) { return ['ok' => false, 'message' => 'PHP ZIP extension not available.']; } if (!defined('BACKUP_PATH')) { return ['ok' => false, 'message' => 'BACKUP_PATH not defined — run setup first.']; } $now = $now ?: time(); $tier = $tier ?: backupTierFor($now); if (!in_array($tier, ['daily', 'weekly', 'monthly', 'yearly', 'manual'], true)) { $tier = 'manual'; } if (!is_dir(BACKUP_PATH)) { @mkdir(BACKUP_PATH, 0755, true); } // Disk space sanity check (need at least 100 MB free for the ZIP). $free = @disk_free_space(BACKUP_PATH); if ($free !== false && $free < 100 * 1024 * 1024) { return ['ok' => false, 'message' => 'Not enough disk space (less than 100 MB free).']; } $siteRoot = realpath(__DIR__ . '/..'); $stamp = date('Y-m-d_His', $now); $filename = backupSiteIdentifier() . "-backup-{$stamp}-{$tier}.zip"; $zipPath = BACKUP_PATH . $filename; // Preserve two manual backups created in the same second. for ($offset = 1; file_exists($zipPath); $offset++) { $stamp = date('Y-m-d_His', $now + $offset); $filename = backupSiteIdentifier() . "-backup-{$stamp}-{$tier}.zip"; $zipPath = BACKUP_PATH . $filename; } @set_time_limit(300); $zip = new ZipArchive(); $opened = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); if ($opened !== true) { return ['ok' => false, 'message' => "Could not create ZIP archive (code $opened)."]; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($siteRoot, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { $filePath = $file->getRealPath(); if ($filePath === false) continue; $rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen($siteRoot) + 1)); if (backupShouldSkipPath($rel)) continue; if ($file->isDir()) { $zip->addEmptyDir($rel); } elseif ($rel === 'content/settings.json') { $zip->addFromString($rel, backupScrubSettingsForArchive($filePath)); } else { $zip->addFile($filePath, $rel); } } $closed = $zip->close(); if (!$closed || !is_file($zipPath)) { if (is_file($zipPath)) @unlink($zipPath); return ['ok' => false, 'message' => 'ZIP file was not written to disk.']; } return [ 'ok' => true, 'file' => $filename, 'size' => filesize($zipPath) ?: 0, 'tier' => $tier, ]; } /** * Apply retention rules and storage budget. Returns the list of files * that were deleted. Algorithm: * 1. Group backups by tier. * 2. In each tier, keep the newest N (config.retention[tier]); delete * the rest. Manual backups are exempt from tier-based pruning. * 3. If total size still exceeds storage_limit_mb, delete oldest * backups (excluding manual) until under budget. */ function backupPrune() { $config = backupConfig(); $all = backupListAll(); $deleted = []; // Tier-based pruning. $byTier = ['daily' => [], 'weekly' => [], 'monthly' => [], 'yearly' => []]; foreach ($all as $b) { if (isset($byTier[$b['tier']])) $byTier[$b['tier']][] = $b; } foreach ($byTier as $tier => $list) { $keep = $config['retention'][$tier] ?? 0; if (count($list) <= $keep) continue; // $list is newest-first (backupListAll sorts that way) foreach (array_slice($list, $keep) as $old) { $path = BACKUP_PATH . $old['file']; if (@unlink($path)) { $deleted[] = $old['file']; } } } // Storage budget enforcement (re-list since tier prune ran). $budgetBytes = (int)$config['storage_limit_mb'] * 1024 * 1024; if ($budgetBytes > 0) { $remaining = backupListAll(); // Sort oldest first for eviction; protect manual backups. $evictable = array_filter($remaining, fn($b) => $b['tier'] !== 'manual'); usort($evictable, fn($a, $b) => $a['mtime'] - $b['mtime']); $total = 0; foreach ($remaining as $b) $total += $b['size']; foreach ($evictable as $b) { if ($total <= $budgetBytes) break; $path = BACKUP_PATH . $b['file']; if (@unlink($path)) { $deleted[] = $b['file']; $total -= $b['size']; } } } return $deleted; } /** * One full scheduled-backup run: create + prune + persist run metadata * to settings.json. Used by the CLI cron entrypoint. */ function backupRun($now = null, $tier = null, $uploadRemote = true) { $now = $now ?: time(); $created = backupCreate($tier, $now); $deleted = $created['ok'] ? backupPrune() : []; $remoteResults = ($created['ok'] && $uploadRemote) ? backupUploadRemoteTargets($created['file']) : []; $remoteErrors = array_filter($remoteResults, fn($result) => empty($result['ok'])); backupSaveConfig([ 'last_run' => date('c', $now), 'last_status' => ($created['ok'] && empty($remoteErrors)) ? 'success' : 'error', 'last_message' => $created['ok'] ? "Created {$created['file']} (" . number_format($created['size'] / 1024 / 1024, 1) . " MB), pruned " . count($deleted) . ", uploaded " . (count($remoteResults) - count($remoteErrors)) . "/" . count($remoteResults) . " remote target(s)." : ($created['message'] ?? 'Unknown error'), ]); return ['created' => $created, 'deleted' => $deleted, 'remote' => $remoteResults]; } /** Quick status snapshot — used by both the dashboard and `cli/backup.php --action=status`. */ function backupStatus() { $config = backupConfig(); $list = backupListAll(); $totalBytes = 0; foreach ($list as $b) $totalBytes += $b['size']; $oldest = !empty($list) ? $list[count($list) - 1]['mtime'] : null; return [ 'enabled' => (bool)$config['enabled'], 'retention' => $config['retention'], 'storage_limit_mb' => $config['storage_limit_mb'], 'cron_mode' => $config['cron_mode'], 'web_cron_token' => backupEnsureWebCronToken(), 'last_run' => $config['last_run'], 'last_status' => $config['last_status'], 'last_message' => $config['last_message'], 'count' => count($list), 'total_bytes' => $totalBytes, 'newest' => $list[0]['mtime'] ?? null, 'oldest' => $oldest, 'remote_providers' => backupRemoteProviders(), 'remote_targets' => backupRemotePublicTargets($config['remote_targets']), ]; }