Add import/export and update system
Implement CSV/JSON import and export for domains, notification groups and tags (with masking for sensitive channel data), including size/format validation, in-memory CSV building, and logging. Add tag transfer and bulk transfer actions (admin-only). Introduce a new update system: Add UpdateController and UpdateService, migration 025_add_update_system_v1.1.3.sql, and installer changes to include the new migration and version handling; provide endpoints to check, apply, rollback and configure updates. Update helpers and UI bits: add getUpdateBadgeInfo in LayoutHelper, update notification icons/redirects, and add getMaxUploadSize in ViewHelper. Misc: add NotificationGroup::findByName, tweak .gitignore backups path, and update related views and routes.
2026-02-11 17:43:23 +02:00
< ? php
namespace App\Controllers ;
use Core\Controller ;
use Core\Auth ;
use App\Services\UpdateService ;
use App\Services\NotificationService ;
use App\Models\Setting ;
use App\Services\Logger ;
class UpdateController extends Controller
{
private UpdateService $updateService ;
private Setting $settingModel ;
private Logger $logger ;
public function __construct ()
{
Auth :: requireAdmin ();
$this -> updateService = new UpdateService ();
$this -> settingModel = new Setting ();
$this -> logger = new Logger ( 'updater' );
}
/**
* AJAX : Check for updates
* POST / api / updates / check
*/
public function check ()
{
if ( $_SERVER [ 'REQUEST_METHOD' ] !== 'POST' ) {
$this -> json ([ 'error' => 'Method not allowed' ], 405 );
return ;
}
$forceCheck = isset ( $_POST [ 'force' ]) && $_POST [ 'force' ] === '1' ;
$result = $this -> updateService -> checkForUpdate ( $forceCheck );
// When manual check finds an update, create in-app notification for admins (once per version/sha)
if ( ! empty ( $result [ 'available' ]) && empty ( $result [ 'error' ])) {
$type = $result [ 'type' ] ? ? 'release' ;
$notifiedRelease = $this -> settingModel -> getValue ( 'last_update_available_notified_release' , '' );
$notifiedHotfixSha = $this -> settingModel -> getValue ( 'last_update_available_notified_hotfix_sha' , '' );
$shouldNotify = false ;
if ( $type === 'release' ) {
$latestVersion = $result [ 'latest_version' ] ? ? '' ;
if ( $latestVersion !== '' && $latestVersion !== $notifiedRelease ) {
$shouldNotify = true ;
$this -> settingModel -> setValue ( 'last_update_available_notified_release' , $latestVersion );
}
} else {
$remoteSha = $result [ 'remote_sha' ] ? ? '' ;
if ( $remoteSha !== '' && $remoteSha !== $notifiedHotfixSha ) {
$shouldNotify = true ;
$this -> settingModel -> setValue ( 'last_update_available_notified_hotfix_sha' , $remoteSha );
}
}
if ( $shouldNotify ) {
try {
$notificationService = new NotificationService ();
$currentVersion = $result [ 'current_version' ] ? ? '' ;
$label = ( $type === 'release' ) ? ( $result [ 'latest_version' ] ? ? 'latest' ) : 'hotfix' ;
$commitsBehind = $result [ 'commits_behind' ] ? ? null ;
$notificationService -> notifyAdminsUpdateAvailable ( $currentVersion , $label , $type , $commitsBehind );
} catch ( \Exception $e ) {
$this -> logger -> warning ( 'Failed to send update-available notification' , [ 'error' => $e -> getMessage ()]);
}
}
}
$this -> json ( $result );
}
/**
* Apply an update ( download , extract , replace files )
* POST / settings / updates / apply
*/
public function apply ()
{
if ( $_SERVER [ 'REQUEST_METHOD' ] !== 'POST' ) {
$this -> redirect ( '/settings#updates' );
return ;
}
// CSRF Protection
$this -> verifyCsrf ( '/settings#updates' );
$type = $_POST [ 'update_type' ] ? ? 'release' ;
if ( ! in_array ( $type , [ 'release' , 'hotfix' ])) {
$_SESSION [ 'error' ] = 'Invalid update type' ;
$this -> redirect ( '/settings#updates' );
return ;
}
$this -> logger -> info ( 'Update requested by admin' , [
'type' => $type ,
'user_id' => Auth :: id (),
]);
$result = $this -> updateService -> performUpdate ( $type );
if ( $result [ 'success' ]) {
$fromVersion = $result [ 'from_version' ];
$toVersion = $result [ 'to_version' ] ? ? 'latest' ;
$filesUpdated = $result [ 'files_updated' ];
// Check for pending migrations after file update
$hasMigrations = $this -> updateService -> hasPendingMigrations ();
2026-02-11 18:44:06 +02:00
// Only notify admins if there are NO pending migrations.
// When migrations are pending, the user is redirected to /install/update
// which sends the upgrade notification after migrations complete (with the correct count).
if ( ! $hasMigrations ) {
try {
$notificationService = new NotificationService ();
2026-02-11 19:55:56 +02:00
// For hotfixes $toVersion is a commit SHA (e.g. "4371f17").
// notifySystemUpgrade() detects the SHA format and includes it
// in the message as "hotfix <sha>".
2026-02-11 18:44:06 +02:00
$notificationService -> notifyAdminsUpgrade (
$fromVersion ,
2026-02-11 19:55:56 +02:00
$toVersion ,
2026-02-11 18:44:06 +02:00
0 ,
! empty ( $result [ 'composer_manual_required' ])
);
} catch ( \Exception $e ) {
// Non-critical
$this -> logger -> warning ( 'Failed to send upgrade notification' , [
'error' => $e -> getMessage (),
]);
}
Add import/export and update system
Implement CSV/JSON import and export for domains, notification groups and tags (with masking for sensitive channel data), including size/format validation, in-memory CSV building, and logging. Add tag transfer and bulk transfer actions (admin-only). Introduce a new update system: Add UpdateController and UpdateService, migration 025_add_update_system_v1.1.3.sql, and installer changes to include the new migration and version handling; provide endpoints to check, apply, rollback and configure updates. Update helpers and UI bits: add getUpdateBadgeInfo in LayoutHelper, update notification icons/redirects, and add getMaxUploadSize in ViewHelper. Misc: add NotificationGroup::findByName, tweak .gitignore backups path, and update related views and routes.
2026-02-11 17:43:23 +02:00
}
$message = " Update applied successfully! { $filesUpdated } file(s) updated. " ;
if ( ! empty ( $result [ 'db_backup_warning' ])) {
$message .= ' Note: Database backup was skipped (' . $result [ 'db_backup_warning' ] . '). Consider backing up your database manually.' ;
}
if ( $hasMigrations ) {
$message .= ' Database migrations are pending - please run them now.' ;
}
if ( ! empty ( $result [ 'composer_manual_required' ])) {
$message .= ' Composer could not be run here (e.g. exec disabled on cPanel). If dependencies changed, run "composer install --no-dev" manually via SSH or Terminal.' ;
}
$_SESSION [ 'success' ] = $message ;
if ( $hasMigrations ) {
$this -> redirect ( '/install/update' );
return ;
}
$this -> redirect ( '/settings#updates' );
} else {
$errors = implode ( '; ' , $result [ 'errors' ]);
$_SESSION [ 'error' ] = " Update failed: { $errors } " ;
$this -> redirect ( '/settings#updates' );
}
}
/**
* Rollback to last backup
* POST / settings / updates / rollback
*/
public function rollback ()
{
if ( $_SERVER [ 'REQUEST_METHOD' ] !== 'POST' ) {
$this -> redirect ( '/settings#updates' );
return ;
}
// CSRF Protection
$this -> verifyCsrf ( '/settings#updates' );
$this -> logger -> info ( 'Rollback requested by admin' , [
'user_id' => Auth :: id (),
]);
$result = $this -> updateService -> rollback ();
if ( $result [ 'success' ]) {
$msg = 'Rollback completed successfully. Files have been restored to the previous version.' ;
if ( isset ( $result [ 'db_restored' ])) {
$msg .= $result [ 'db_restored' ]
? ' Database has also been restored from the backup.'
: ' Database could not be restored automatically. You can import the SQL backup manually from the backups/ directory.' ;
}
$_SESSION [ 'success' ] = $msg ;
} else {
$_SESSION [ 'error' ] = $result [ 'error' ] ? ? 'Rollback failed' ;
}
$this -> redirect ( '/settings#updates' );
}
/**
* Save update preferences ( channel + badge ) from single form
* POST / settings / updates / preferences
*/
public function savePreferences ()
{
if ( $_SERVER [ 'REQUEST_METHOD' ] !== 'POST' ) {
$this -> redirect ( '/settings#updates' );
return ;
}
$this -> verifyCsrf ( '/settings#updates' );
$channel = $_POST [ 'update_channel' ] ? ? 'stable' ;
if ( ! in_array ( $channel , [ 'stable' , 'latest' ])) {
$_SESSION [ 'error' ] = 'Invalid update channel' ;
$this -> redirect ( '/settings#updates' );
return ;
}
$badgeEnabled = isset ( $_POST [ 'update_badge_enabled' ]) && $_POST [ 'update_badge_enabled' ] === '1' ? '1' : '0' ;
$this -> settingModel -> setValue ( 'update_channel' , $channel );
$this -> settingModel -> setValue ( 'update_badge_enabled' , $badgeEnabled );
if ( $channel === 'latest' ) {
$currentSha = $this -> settingModel -> getValue ( 'installed_commit_sha' , null );
if ( ! $currentSha ) {
$_SESSION [ 'info' ] = 'Update preferences saved. Note: Commit tracking will begin after the first update is applied.' ;
} else {
$_SESSION [ 'success' ] = 'Update preferences saved.' ;
}
} else {
$_SESSION [ 'success' ] = 'Update preferences saved.' ;
}
$this -> settingModel -> setValue ( 'last_update_check' , null );
$this -> redirect ( '/settings#updates' );
}
/**
* Update the update channel preference
* POST / settings / updates / channel
*/
public function updateChannel ()
{
if ( $_SERVER [ 'REQUEST_METHOD' ] !== 'POST' ) {
$this -> redirect ( '/settings#updates' );
return ;
}
// CSRF Protection
$this -> verifyCsrf ( '/settings#updates' );
$channel = $_POST [ 'update_channel' ] ? ? 'stable' ;
if ( ! in_array ( $channel , [ 'stable' , 'latest' ])) {
$_SESSION [ 'error' ] = 'Invalid update channel' ;
$this -> redirect ( '/settings#updates' );
return ;
}
$this -> settingModel -> setValue ( 'update_channel' , $channel );
// If switching to "latest" and no commit SHA is tracked, try to fetch it
if ( $channel === 'latest' ) {
$currentSha = $this -> settingModel -> getValue ( 'installed_commit_sha' , null );
if ( ! $currentSha ) {
$_SESSION [ 'info' ] = 'Update channel set to Latest. Note: Commit tracking will begin after the first update is applied. Until then, only release updates will be detected.' ;
} else {
$_SESSION [ 'success' ] = 'Update channel set to Latest. You will now receive both releases and hotfix updates.' ;
}
} else {
$_SESSION [ 'success' ] = 'Update channel set to Stable. You will only receive tagged release updates.' ;
}
// Clear cached check results so next check uses new channel
$this -> settingModel -> setValue ( 'last_update_check' , null );
$this -> redirect ( '/settings#updates' );
}
/**
* Update the " show update badge in menu " preference
* POST / settings / updates / badge
*/
public function updateBadgePreference ()
{
if ( $_SERVER [ 'REQUEST_METHOD' ] !== 'POST' ) {
$this -> redirect ( '/settings#updates' );
return ;
}
$this -> verifyCsrf ( '/settings#updates' );
$enabled = isset ( $_POST [ 'update_badge_enabled' ]) && $_POST [ 'update_badge_enabled' ] === '1' ? '1' : '0' ;
$this -> settingModel -> setValue ( 'update_badge_enabled' , $enabled );
$_SESSION [ 'success' ] = $enabled === '1'
? 'Update badge will be shown in the top menu when an update is available.'
: 'Update badge in the top menu is now disabled.' ;
$this -> redirect ( '/settings#updates' );
}
}