diff --git a/.gitignore b/.gitignore index 0e69ddeac7..df9931cce2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ composer.lock checksums.json /.node_cache/ /release/ +/backups/ diff --git a/composer.json b/composer.json index ac55acfb57..950a2855e2 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "geoip2/geoip2": "^3.0", "jenssegers/agent": "^2.6", "php-di/php-di": "^7.0", - "twig/twig": "^3.0" + "twig/twig": "^3.0", + "druidfi/mysqldump-php": "^2.0" }, "require-dev": { "phpstan/phpstan": "1.6.9", diff --git a/core/classes/Core/Util.php b/core/classes/Core/Util.php index aeb007627a..893c1c9277 100644 --- a/core/classes/Core/Util.php +++ b/core/classes/Core/Util.php @@ -303,4 +303,18 @@ public static function isCompatible(string $version, string $nameless_version): return $major == $nameless_major && $minor == $nameless_minor; } + + /** + * Format bytes into a human-readable string. + * + * @param int $bytes Number of bytes to format. + * @return string Formatted string. + */ + public static function formatBytes(int $bytes): string + { + $sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + $factor = floor((strlen($bytes) - 1) / 3); + + return sprintf('%.2f', $bytes / pow(1024, $factor)) . $sizes[$factor]; + } } diff --git a/core/classes/Database/DatabaseInitialiser.php b/core/classes/Database/DatabaseInitialiser.php index 9edf10146f..437e36586b 100644 --- a/core/classes/Database/DatabaseInitialiser.php +++ b/core/classes/Database/DatabaseInitialiser.php @@ -46,7 +46,7 @@ private function initialiseGroups(): void 'group_username_color' => '#ff0000', 'group_username_css' => '', 'admin_cp' => true, - 'permissions' => '{"administrator":1,"admincp.core":1,"admincp.core.api":1,"admincp.core.seo":1,"admincp.core.general":1,"admincp.core.avatars":1,"admincp.core.fields":1,"admincp.core.debugging":1,"admincp.core.emails":1,"admincp.core.queue":1,"admincp.core.navigation":1,"admincp.core.announcements":1,"admincp.core.reactions":1,"admincp.core.registration":1,"admincp.core.social_media":1,"admincp.core.terms":1,"admincp.errors":1,"admincp.core.placeholders":1,"admincp.members":1,"admincp.integrations":1,"admincp.integrations.edit":1,"admincp.discord":1,"admincp.minecraft":1,"admincp.minecraft.authme":1,"admincp.minecraft.servers":1,"admincp.minecraft.query_errors":1,"admincp.minecraft.banners":1,"admincp.modules":1,"admincp.pages":1,"admincp.security":1,"admincp.security.acp_logins":1,"admincp.security.template":1,"admincp.styles":1,"admincp.styles.panel_templates":1,"admincp.styles.templates":1,"admincp.styles.templates.edit":1,"admincp.styles.images":1,"admincp.update":1,"admincp.users":1,"admincp.users.edit":1,"admincp.groups":1,"admincp.groups.self":1,"admincp.widgets":1,"modcp.ip_lookup":1,"modcp.punishments":1,"modcp.punishments.warn":1,"modcp.punishments.ban":1,"modcp.punishments.banip":1,"modcp.punishments.revoke":1,"modcp.reports":1,"modcp.profile_banner_reset":1,"usercp.messaging":1,"usercp.signature":1,"admincp.forums":1,"usercp.private_profile":1,"usercp.nickname":1,"usercp.title":1,"usercp.profile_banner":1,"profile.private.bypass":1, "admincp.security.all":1,"admincp.core.hooks":1,"admincp.security.group_sync":1,"admincp.core.emails_mass_message":1,"modcp.punishments.reset_avatar":1,"usercp.gif_avatar":1,"profile.post":1}', + 'permissions' => '{"administrator":1,"admincp.core":1,"admincp.core.api":1,"admincp.core.seo":1,"admincp.core.general":1,"admincp.core.avatars":1,"admincp.core.fields":1,"admincp.core.debugging":1,"admincp.core.backups":1."admincp.core.emails":1,"admincp.core.queue":1,"admincp.core.navigation":1,"admincp.core.announcements":1,"admincp.core.reactions":1,"admincp.core.registration":1,"admincp.core.social_media":1,"admincp.core.terms":1,"admincp.errors":1,"admincp.core.placeholders":1,"admincp.members":1,"admincp.integrations":1,"admincp.integrations.edit":1,"admincp.discord":1,"admincp.minecraft":1,"admincp.minecraft.authme":1,"admincp.minecraft.servers":1,"admincp.minecraft.query_errors":1,"admincp.minecraft.banners":1,"admincp.modules":1,"admincp.pages":1,"admincp.security":1,"admincp.security.acp_logins":1,"admincp.security.template":1,"admincp.styles":1,"admincp.styles.panel_templates":1,"admincp.styles.templates":1,"admincp.styles.templates.edit":1,"admincp.styles.images":1,"admincp.update":1,"admincp.users":1,"admincp.users.edit":1,"admincp.groups":1,"admincp.groups.self":1,"admincp.widgets":1,"modcp.ip_lookup":1,"modcp.punishments":1,"modcp.punishments.warn":1,"modcp.punishments.ban":1,"modcp.punishments.banip":1,"modcp.punishments.revoke":1,"modcp.reports":1,"modcp.profile_banner_reset":1,"usercp.messaging":1,"usercp.signature":1,"admincp.forums":1,"usercp.private_profile":1,"usercp.nickname":1,"usercp.profile_banner":1,"profile.private.bypass":1, "admincp.security.all":1,"admincp.core.hooks":1,"admincp.security.group_sync":1,"admincp.core.emails_mass_message":1,"modcp.punishments.reset_avatar":1,"usercp.gif_avatar":1,"profile.post":1}', 'order' => 1, 'staff' => true, ]); diff --git a/core/includes/updates/230.php b/core/includes/updates/230.php index b36c2309db..50087c8756 100644 --- a/core/includes/updates/230.php +++ b/core/includes/updates/230.php @@ -66,6 +66,16 @@ public function run(): void Settings::set('discord_widget_theme', $discord_widget_theme, 'Discord Integration'); $this->_cache->eraseAll(); + // Add admincp.core.backups permission to Admin group + $admin_group = Group::find(1); + $permissions = json_decode($admin_group->permissions, true); + if (!isset($permissions['admincp.core.backups'])) { + $permissions['admincp.core.backups'] = 1; + DB::getInstance()->update('groups', $admin_group->id, [ + 'permissions' => json_encode($permissions), + ]); + } + $this->setVersion('2.3.0'); } }; diff --git a/core/init.php b/core/init.php index 70ab4cd5d4..9781eaa137 100644 --- a/core/init.php +++ b/core/init.php @@ -34,6 +34,7 @@ ROOT_PATH . '/cache/logs', ROOT_PATH . '/cache/sitemaps', ROOT_PATH . '/cache/templates_c', + ROOT_PATH . '/cache/backups', ROOT_PATH . '/uploads', ROOT_PATH . '/core/config.php', ]; diff --git a/custom/panel_templates/Default/core/backups.tpl b/custom/panel_templates/Default/core/backups.tpl new file mode 100644 index 0000000000..e8608ed680 --- /dev/null +++ b/custom/panel_templates/Default/core/backups.tpl @@ -0,0 +1,154 @@ +{include file='header.tpl'} + + + + +
+ + + {include file='sidebar.tpl'} + + +
+ + +
+ + + {include file='navbar.tpl'} + + +
+ + +
+

{$DEBUGGING_AND_MAINTENANCE}

+ +
+ + + {include file='includes/update.tpl'} + +
+
+ +
{$BACKUPS}
+ + +
+ + + {include file='includes/alerts.tpl'} + +
+
+
{$INFO}
+ {$BACKUPS_INFO} +
+
+ +
+ + +
+
+
{$BACKUP_SETTINGS}
+ +
+
+
+ + + {$MAX_BACKUP_RETENTION_INFO} +
+
+ + + {$DAILY_BACKUP_SCHEDULING_INFO} +
+
+
+ + + +
+
+
+
+ + + {if isset($EXISTING_BACKUPS) && count($EXISTING_BACKUPS) > 0} +
{$EXISTING}
+ +
+ + + + + + + {if $CAN_DOWNLOAD} + + {/if} + + + + {foreach from=$EXISTING_BACKUPS item=backup} + + + + + {if $CAN_DOWNLOAD} + + {/if} + + {/foreach} + +
{$FILENAME}{$DATE_CREATED}{$FILE_SIZE}{$ACTIONS}
{$backup.filename}{$backup.date}{$backup.size} + + {$DOWNLOAD} + +
+
+ {else} +
+ {$NO_BACKUPS} +
+ {/if} +
+
+ + +
+ + +
+ + +
+ + {include file='footer.tpl'} + + +
+ + +
+ + {include file='scripts.tpl'} + + + + diff --git a/custom/panel_templates/Default/core/debugging_and_maintenance.tpl b/custom/panel_templates/Default/core/debugging_and_maintenance.tpl index 546c2bbdff..a1c19677e7 100644 --- a/custom/panel_templates/Default/core/debugging_and_maintenance.tpl +++ b/custom/panel_templates/Default/core/debugging_and_maintenance.tpl @@ -36,7 +36,11 @@
{if isset($ERROR_LOGS)} - {$ERROR_LOGS} + {$ERROR_LOGS} + {/if} + + {if isset($BACKUPS_LINK)} + {$BACKUPS} {/if}
+ + {if isset($NEW_UPDATE) && isset($BACKUP_RECOMMENDATION)} +
+
+
+ {$BACKUP_RECOMMENDATION} +
+
+
+

{$BACKUP_BEFORE_UPDATE}

+ +
+
+
{$MOST_RECENT_BACKUP}
+ {if isset($LATEST_BACKUP)} +
+ + {$LATEST_BACKUP.filename}
+ {$LATEST_BACKUP.date_formatted} +
+ {else} +
+ + {$NO_RECENT_BACKUP} +
+ {/if} +
+ +
+
+
+ {/if} +
diff --git a/modules/Core/classes/Tasks/Backup.php b/modules/Core/classes/Tasks/Backup.php new file mode 100644 index 0000000000..85f1de9fd3 --- /dev/null +++ b/modules/Core/classes/Tasks/Backup.php @@ -0,0 +1,276 @@ +.zip'. + */ + public function run(): string + { + $backupsFolder = ROOT_PATH . '/cache/backups/'; + + if (!$this->backupsFolderWritable($backupsFolder)) { + return Task::STATUS_ERROR; + } + + if (!$this->hasDiskSpace()) { + return Task::STATUS_ERROR; + } + + $tempBackupFolder = $backupsFolder . date('Y-m-d_H-i-s') . '/'; + if (!is_dir($tempBackupFolder)) { + mkdir($tempBackupFolder, 0755, true); + } + + if (!$this->backupDatabase($tempBackupFolder)) { + return Task::STATUS_ERROR; + } + + if (!$this->backupFiles($tempBackupFolder)) { + return Task::STATUS_ERROR; + } + + if (!$this->createZipArchive($backupsFolder, $tempBackupFolder)) { + return Task::STATUS_ERROR; + } + + $maxRetention = (int) Settings::get('backup_max_retention', '5'); + if ($maxRetention > 0) { + $this->cleanupOldBackups($backupsFolder, $maxRetention); + } + + if ($this->getName() === self::DAILY_BACKUP && Settings::get('backup_daily_scheduling', '0')) { + self::scheduleNextDailyBackup(); + + $this->setOutput([ + 'schedule' => 'Next daily backup scheduled successfully', + ]); + } + + return Task::STATUS_COMPLETED; + } + + private function backupsFolderWritable(string $backupsFolder): bool + { + if (is_dir($backupsFolder) && !is_writable($backupsFolder)) { + $this->setOutput([ + 'error' => $backupsFolder . ' is not writable. Please check permissions.', + ]); + return false; + } + + return true; + } + + private function hasDiskSpace(): bool + { + $dbConfig = Config::get('mysql'); + $dbName = $dbConfig['db']; + + // Get the size of the database + $sizeQuery = DB::getInstance()->query("SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size FROM information_schema.TABLES WHERE table_schema = ?", [$dbName]); + $databaseSizeEstimate = $sizeQuery->first()->size ?? 0; + $databaseSizeEstimate = $databaseSizeEstimate * 1024 * 1024; + + // ~50 MB for files, mostly due to the possibility of many image uploads + $fileSizeEstimate = 50 * 1024 * 1024; + + $totalEstimatedSize = $databaseSizeEstimate + $fileSizeEstimate; + $freeSpace = disk_free_space(ROOT_PATH); + + if ($totalEstimatedSize > $freeSpace) { + $this->setOutput([ + 'error' => 'Not enough disk space for backup. Estimated size: ' . Util::formatBytes($totalEstimatedSize) . ', free space: ' . Util::formatBytes($freeSpace), + ]); + return false; + } + + return true; + } + + private function backupDatabase(string $tempBackupFolder): bool + { + $dbConfig = Config::get('mysql'); + $dbHost = $dbConfig['host']; + $dbPort = $dbConfig['port']; + $dbName = $dbConfig['db']; + $dbUsername = $dbConfig['username']; + $dbPassword = $dbConfig['password']; + + // Dump the database to the temporary folder + try { + $dump = new Mysqldump("mysql:host={$dbHost};port={$dbPort};dbname={$dbName}", $dbUsername, $dbPassword); + $dump->start($tempBackupFolder . 'database.sql'); + } catch (Exception $e) { + $this->setOutput([ + 'error' => 'Database backup failed: ' . $e->getMessage(), + ]); + return false; + } + + return true; + } + + private function backupFiles(string $tempBackupFolder): bool + { + $source = ROOT_PATH . '/'; + $destination = $tempBackupFolder . 'nameless/'; + + mkdir($destination, 0755, true); + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + $subPath = str_replace($source, '', $file->getPathname()); + + // Skip excluded directories + $shouldSkip = false; + foreach (self::EXCLUDED_DIRS as $excludedDir) { + if (str_starts_with($subPath, $excludedDir . '/') || $subPath === $excludedDir) { + $shouldSkip = true; + break; + } + } + + if ($shouldSkip) { + continue; + } + + $destPath = $destination . $subPath; + + if ($file->isDir()) { + if (!is_dir($destPath)) { + mkdir($destPath, 0755, true); + } + } else { + copy($file->getPathname(), $destPath); + } + } + + return true; + } + + private function createZipArchive(string $backupsFolder, string $tempBackupFolder): bool + { + $zipFileLocation = $backupsFolder . '/nameless_backup_' . date('Y-m-d_H-i-s') . '.zip'; + + $zip = new ZipArchive(); + if ($zip->open($zipFileLocation, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + $this->setOutput([ + 'error' => 'Failed to create zip archive', + ]); + $this->deleteFolder($tempBackupFolder); + return false; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($tempBackupFolder, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + $subPath = str_replace($tempBackupFolder, '', $file->getPathname()); + if ($file->isDir()) { + $zip->addEmptyDir($subPath); + } else { + $zip->addFile($file->getPathname(), $subPath); + } + } + $zip->close(); + + // Clean up the temporary backup folder + $this->deleteFolder($tempBackupFolder); + + $this->setOutput([ + 'result' => 'Backup created successfully', + 'file' => $zipFileLocation, + ]); + + return true; + } + + private function deleteFolder(string $folder): void + { + if (!is_dir($folder)) { + return; + } + + $files = array_diff(scandir($folder), ['.', '..']); + foreach ($files as $file) { + is_dir("$folder/$file") ? $this->deleteFolder("$folder/$file") : unlink("$folder/$file"); + } + + rmdir($folder); + } + + /** + * Clean up old backups based on the max retention setting + */ + private function cleanupOldBackups(string $backupsFolder, int $maxRetention): void + { + $backupFiles = glob($backupsFolder . 'nameless_backup_*.zip'); + + if (count($backupFiles) <= $maxRetention) { + return; + } + + usort($backupFiles, function ($a, $b) { + return filemtime($b) - filemtime($a); + }); + + $filesToDelete = array_slice($backupFiles, $maxRetention); + foreach ($filesToDelete as $file) { + unlink($file); + } + + $this->setOutput([ + 'cleanup' => count($filesToDelete) . ' backups cleaned up successfully', + ]); + } + + /** + * Schedule the next daily backup + */ + public static function scheduleNextDailyBackup(): void + { + // Cancel any existing scheduled daily backups to avoid duplicates + self::unscheduleNextDailyBackup(); + + $task = (new Backup())->fromNew( + Module::getIdFromName('Core'), + self::DAILY_BACKUP, + null, + Date::next()->getTimestamp(), + ); + Queue::schedule($task); + } + + /** + * Unschedule the next daily backup + */ + public static function unscheduleNextDailyBackup(): void + { + DB::getInstance()->delete('queue', [ + ['task', Backup::class], + ['name', self::DAILY_BACKUP], + ['status', Task::STATUS_READY] + ]); + } +} diff --git a/modules/Core/language/en_UK.json b/modules/Core/language/en_UK.json index 81e1bba545..3e07326094 100644 --- a/modules/Core/language/en_UK.json +++ b/modules/Core/language/en_UK.json @@ -48,6 +48,27 @@ "admin/auto_language_help": "If enabled, guests & logged out users will be able to let the website automatically detect their preferred language.", "admin/avatar_settings_updated_successfully": "Avatar settings updated successfully.", "admin/avatars": "Avatars", + "admin/backups": "Backups", + "admin/backups_info": "Backups allow you to create a copy of your NamelessMC website files and database. This is useful for migrating to a new server or for restoring your website in case of an issue.", + "admin/backup_in_progress": "Backup creation has been queued. Check the queue status for progress.", + "admin/create_backup": "Create Backup", + "admin/date_created": "Date Created", + "admin/file_size": "File Size", + "admin/filename": "Filename", + "admin/no_backups": "No backups found. Create your first backup to get started.", + "admin/existing_backups": "Existing Backups", + "admin/backup_settings": "Backup Settings", + "admin/max_backup_retention": "Maximum Backup Retention", + "admin/max_backup_retention_info": "Maximum number of backups to keep. Older backups will be automatically deleted when this limit is exceeded. Set to 0 to keep all backups.", + "admin/daily_backup_scheduling": "Daily Backup Scheduling", + "admin/daily_backup_scheduling_info": "Automatically create a new backup daily. The backup will be scheduled to run after the previous backup completes.", + "admin/backup_settings_updated": "Backup settings updated successfully.", + "admin/backup_recommendation": "Backup Recommendation", + "admin/backup_before_update": "It's strongly recommended to create a backup before updating your website. This allows you to restore your site if any issues occur during the update process.", + "admin/most_recent_backup": "Most Recent Backup", + "admin/backup_created": "Created {{ago}}", + "admin/no_recent_backup": "No backups found", + "admin/manage_backups": "Manage Backups", "admin/background_colour": "Background Colour", "admin/background_colour_required": "Background Colour is required", "admin/ban_hook_info": "User banned", @@ -447,6 +468,7 @@ "admin/outgoing_email": "Outgoing Email Address", "admin/outgoing_email_info": "This is the email address which NamelessMC will use to send emails from.", "admin/overview": "Overview", + "admin/only_root_user_can_download_backups": "Only the root user can download backups.", "admin/page": "Page", "admin/page_content": "Page Content", "admin/page_content_maximum_100000": "The page content must be a maximum of 100000 characters.", diff --git a/modules/Core/module.php b/modules/Core/module.php index d9be3ffc9e..19617af46d 100644 --- a/modules/Core/module.php +++ b/modules/Core/module.php @@ -80,6 +80,7 @@ public function __construct(Language $language, Pages $pages, User $user, Naviga $pages->add('Core', '/panel/core/avatars', 'pages/panel/avatars.php'); $pages->add('Core', '/panel/core/profile_fields', 'pages/panel/profile_fields.php'); $pages->add('Core', '/panel/core/debugging_and_maintenance', 'pages/panel/debugging_and_maintenance.php'); + $pages->add('Core', '/panel/core/backups', 'pages/panel/backups.php'); $pages->add('Core', '/panel/core/errors', 'pages/panel/errors.php'); $pages->add('Core', '/panel/core/emails', 'pages/panel/emails.php'); $pages->add('Core', '/panel/core/emails/errors', 'pages/panel/emails_errors.php'); @@ -546,6 +547,7 @@ public function onPageLoad( 'admincp.core.fields' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'custom_fields'), 'admincp.core.debugging' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'debugging_and_maintenance'), 'admincp.errors' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'debugging_and_maintenance') . ' » ' . $language->get('admin', 'error_logs'), + 'admincp.core.backups' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'debugging_and_maintenance') . ' » ' . $language->get('admin', 'backups'), 'admincp.core.emails' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'emails'), 'admincp.core.emails_mass_message' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'mass_message'), 'admincp.core.navigation' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'navigation'), diff --git a/modules/Core/pages/panel/backups.php b/modules/Core/pages/panel/backups.php new file mode 100644 index 0000000000..37d15e480d --- /dev/null +++ b/modules/Core/pages/panel/backups.php @@ -0,0 +1,177 @@ +handlePanelPageLoad('admincp.backups')) { + require_once ROOT_PATH . '/403.php'; + die(); +} + +const PAGE = 'panel'; +const PARENT_PAGE = 'core_configuration'; +const PANEL_PAGE = 'debugging_and_maintenance'; +$page_title = $language->get('admin', 'backups'); +require_once ROOT_PATH . '/core/templates/backend_init.php'; + +if (isset($_GET['action']) && $_GET['action'] == 'create') { + if (Token::check($_GET['token'])) { + $task = (new Backup())->fromNew( + Module::getIdFromName('Core'), + Backup::MANUAL_BACKUP, + null, + date('U'), + null, + null, + false, + null, + $user->data()->id, + ); + Queue::schedule($task); + + Session::flash('backup_success', $language->get('admin', 'backup_in_progress')); + Redirect::to(URL::build('/panel/core/backups')); + } else { + Session::flash('backup_error', $language->get('general', 'invalid_token')); + } +} + +// Handle settings form submission +if (Input::exists() && Input::get('action') == 'settings') { + if (Token::check()) { + $max_retention = Input::get('max_backup_retention'); + $daily_scheduling = Input::get('daily_backup_scheduling') === '1' ? '1' : '0'; + + Settings::set('backup_max_retention', $max_retention); + Settings::set('backup_daily_scheduling', $daily_scheduling); + + // If daily scheduling is enabled, schedule the next backup, otherwise unschedule it + if ($daily_scheduling) { + Backup::scheduleNextDailyBackup(); + } else { + Backup::unscheduleNextDailyBackup(); + } + + Session::flash('backup_success', $language->get('admin', 'backup_settings_updated')); + Redirect::to(URL::build('/panel/core/backups')); + } else { + Session::flash('backup_error', $language->get('general', 'invalid_token')); + Redirect::to(URL::build('/panel/core/backups')); + } +} + +$backups_dir = ROOT_PATH . '/cache/backups/'; + +// Handle download request +if (isset($_GET['download']) && !empty($_GET['download'])) { + // Only allow root user to download backups + if ($user->data()->id == 1) { + $filename = basename($_GET['download']); + $filepath = $backups_dir . $filename; + + if (file_exists($filepath) && pathinfo($filepath, PATHINFO_EXTENSION) === 'zip') { + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . filesize($filepath)); + readfile($filepath); + exit; + } + } else { + Session::flash('backup_error', $language->get('admin', 'only_root_user_can_download_backups')); + Redirect::to(URL::build('/panel/core/backups')); + } +} + +// Get backup information +$backups = []; +if (is_dir($backups_dir)) { + $backup_files = glob($backups_dir . '*.zip'); + if ($backup_files) { + usort($backup_files, function($a, $b) { + return filemtime($b) - filemtime($a); + }); + + foreach ($backup_files as $backup_file) { + $backups[] = [ + 'filename' => basename($backup_file), + 'date' => date(DATE_FORMAT, filemtime($backup_file)), + 'size' => Util::formatBytes(filesize($backup_file)), + 'download_link' => URL::build('/panel/core/backups', 'download=' . urlencode(basename($backup_file))), + ]; + } + } +} + +// Load modules + template +Module::loadPage($user, $pages, $cache, $smarty, [$navigation, $cc_nav, $staffcp_nav], $widgets, $template); + +if (Session::exists('backup_success')) { + $template->getEngine()->addVariables([ + 'SUCCESS' => Session::flash('backup_success'), + 'SUCCESS_TITLE' => $language->get('general', 'success'), + ]); +} + +if (Session::exists('backup_error')) { + $template->getEngine()->addVariables([ + 'ERRORS' => [Session::flash('backup_error')], + 'ERRORS_TITLE' => $language->get('general', 'error'), + ]); +} + +$template->getEngine()->addVariables([ + 'PARENT_PAGE' => PARENT_PAGE, + 'DASHBOARD' => $language->get('admin', 'dashboard'), + 'CONFIGURATION' => $language->get('admin', 'configuration'), + 'DEBUGGING_AND_MAINTENANCE' => $language->get('admin', 'debugging_and_maintenance'), + 'PAGE' => PANEL_PAGE, + 'BACK' => $language->get('general', 'back'), + 'BACK_LINK' => URL::build('/panel/core/debugging_and_maintenance'), + 'TOKEN' => Token::get(), + 'BACKUPS' => $language->get('admin', 'backups'), + 'BACKUPS_INFO' => $language->get('admin', 'backups_info'), + 'CREATE_BACKUP' => $language->get('admin', 'create_backup'), + 'CREATE_BACKUP_LINK' => URL::build('/panel/core/backups', 'action=create&token=' . Token::get()), + 'EXISTING_BACKUPS' => $backups, + 'NO_BACKUPS' => $language->get('admin', 'no_backups'), + 'FILENAME' => $language->get('admin', 'filename'), + 'DATE_CREATED' => $language->get('admin', 'date_created'), + 'ACTIONS' => $language->get('general', 'actions'), + 'FILE_SIZE' => $language->get('admin', 'file_size'), + 'CAN_DOWNLOAD' => $user->data()->id == 1, + 'DOWNLOAD' => $language->get('admin', 'download'), + 'INFO' => $language->get('general', 'info'), + 'EXISTING' => $language->get('admin', 'existing_backups'), + 'BACKUP_SETTINGS' => $language->get('admin', 'backup_settings'), + 'MAX_BACKUP_RETENTION' => $language->get('admin', 'max_backup_retention'), + 'MAX_BACKUP_RETENTION_INFO' => $language->get('admin', 'max_backup_retention_info'), + 'MAX_BACKUP_RETENTION_VALUE' => Settings::get('backup_max_retention', '5'), + 'DAILY_BACKUP_SCHEDULING' => $language->get('admin', 'daily_backup_scheduling'), + 'DAILY_BACKUP_SCHEDULING_INFO' => $language->get('admin', 'daily_backup_scheduling_info'), + 'DAILY_BACKUP_SCHEDULING_VALUE' => Settings::get('backup_daily_scheduling', '0'), + 'ENABLED' => $language->get('admin', 'enabled'), + 'DISABLED' => $language->get('admin', 'disabled'), + 'SUBMIT' => $language->get('general', 'submit'), +]); + +$template->onPageLoad(); + +require ROOT_PATH . '/core/templates/panel_navbar.php'; + +// Display template +$template->displayTemplate('core/backups'); diff --git a/modules/Core/pages/panel/debugging_and_maintenance.php b/modules/Core/pages/panel/debugging_and_maintenance.php index ad8e384eaa..48839ab251 100644 --- a/modules/Core/pages/panel/debugging_and_maintenance.php +++ b/modules/Core/pages/panel/debugging_and_maintenance.php @@ -92,6 +92,13 @@ ]); } +if ($user->hasPermission('admincp.backups')) { + $template->getEngine()->addVariables([ + 'BACKUPS' => $language->get('admin', 'backups'), + 'BACKUPS_LINK' => URL::build('/panel/core/backups'), + ]); +} + $template->getEngine()->addVariables([ 'PARENT_PAGE' => PARENT_PAGE, 'DASHBOARD' => $language->get('admin', 'dashboard'), diff --git a/modules/Core/pages/panel/update.php b/modules/Core/pages/panel/update.php index 8bdd6a78b5..c3bb6c6ee9 100644 --- a/modules/Core/pages/panel/update.php +++ b/modules/Core/pages/panel/update.php @@ -78,6 +78,42 @@ 'DOWNLOAD' => $language->get('admin', 'download'), 'INSTALL_CONFIRM' => $language->get('admin', 'install_confirm'), ]); + + // Get backup information + if ($user->hasPermission('admincp.core.backups')) { + $latest_backup = null; + $backups_dir = ROOT_PATH . '/backups/'; + if (is_dir($backups_dir)) { + $backup_files = glob($backups_dir . '*.zip'); + if ($backup_files) { + // Sort by modification time (newest first) + usort($backup_files, function($a, $b) { + return filemtime($b) - filemtime($a); + }); + + $latest_backup = [ + 'filename' => basename($backup_files[0]), + 'date' => date(DATE_FORMAT, filemtime($backup_files[0])), + 'date_formatted' => $language->get('admin', 'backup_created', [ + 'ago' => (new TimeAgo(TIMEZONE))->inWords(date(DATE_FORMAT, filemtime($backup_files[0])), $language) + ]), + 'timestamp' => filemtime($backup_files[0]) + ]; + } + } + + $template->getEngine()->addVariables([ + 'BACKUP_RECOMMENDATION' => $language->get('admin', 'backup_recommendation'), + 'BACKUP_BEFORE_UPDATE' => $language->get('admin', 'backup_before_update'), + 'MOST_RECENT_BACKUP' => $language->get('admin', 'most_recent_backup'), + 'NO_RECENT_BACKUP' => $language->get('admin', 'no_recent_backup'), + 'CREATE_BACKUP' => $language->get('admin', 'create_backup'), + 'MANAGE_BACKUPS' => $language->get('admin', 'manage_backups'), + 'CREATE_BACKUP_LINK' => URL::build('/panel/core/backups', 'action=create&token=' . Token::get()), + 'BACKUPS_PAGE_LINK' => URL::build('/panel/core/backups'), + 'LATEST_BACKUP' => $latest_backup, + ]); + } } } else { $template->getEngine()->addVariable('UPDATE_CHECK_ERROR', $update_check);