Raiju/includes/backup-helper.php
2026-10-25 06:05:42 +03:00

1885 lines
74 KiB
PHP

<?php
/**
* Site backup helper.
*
* One-stop module for everything related to whole-site ZIP backups:
* creation, retention pruning (grandfather-father-son), storage
* accounting, and schedule decisions. Used by both the on-demand
* admin API endpoint and the cron-driven CLI runner so the two paths
* can never drift apart.
*
* Configuration lives in content/settings.json under the top-level
* `backup` key — see backupConfig() for defaults.
*
* Backup filenames follow this scheme so the prune algorithm can
* group them by tier:
* {site-domain}-backup-YYYY-MM-DD_HHMMSS-{tier}.zip
* where {tier} is daily | weekly | monthly | yearly | manual.
*
* IMPORTANT: this file must NOT depend on $_SESSION, $_GET, or any
* web-request state — it has to run from a CLI cron context where
* none of that exists.
*/
if (!defined('BACKUP_PATH')) {
// Allow standalone include from CLI before config.php has run.
$configPath = __DIR__ . '/../admin/config.php';
if (is_file($configPath)) {
require_once $configPath;
}
}
if (!class_exists('BackupLockException')) {
class BackupLockException extends RuntimeException {}
}
/** Default backup config — merged over content/settings.json `backup` key. */
function backupDefaults() {
return [
'enabled' => 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']),
];
}