WIP S3 stream-wrapper: refactored DB backup in order to (optionally) use S3

This commit is contained in:
ralf 2023-10-24 22:22:16 +03:00
parent effa3686c8
commit b45b3c35df
3 changed files with 187 additions and 109 deletions

View File

@ -3,36 +3,55 @@
* EGroupware - Admin - DB backup and restore * EGroupware - Admin - DB backup and restore
* *
* @link http://www.egroupware.org * @link http://www.egroupware.org
* @author Ralf Becker <RalfBecker@outdoor-training.de> * @author Ralf Becker <rb@egroupware.org>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package admin * @package admin
* @version $Id$
*/ */
use EGroupware\Api; use EGroupware\Api;
use EGroupware\Stylite\Vfs\S3;
class admin_db_backup class admin_db_backup
{ {
var $public_functions = array( /**
* @var true[]
*/
public $public_functions = array(
'index' => true, 'index' => true,
); );
var $db_backup; /**
* @var Api\Db\Backup
*/
protected $db_backup;
/** /**
* Method for sheduled backups, called via asynservice * Method for scheduled backups, called via asynservice
*/ */
function do_backup() function do_backup()
{ {
$this->db_backup = new Api\Db\Backup(); if (class_exists(S3\Backup::class) && S3\Backup::available())
if (($f = $this->db_backup->fopen_backup()))
{ {
$this->db_backup = new S3\Backup();
}
else
{
$this->db_backup = new Api\Db\Backup();
}
try {
$f = $this->db_backup->fopen_backup();
$this->db_backup->backup($f); $this->db_backup->backup($f);
if(is_resource($f)) if (is_resource($f))
{
fclose($f); fclose($f);
}
/* Remove old backups. */ /* Remove old backups. */
$this->db_backup->housekeeping(); $this->db_backup->housekeeping();
} }
catch (\Exception $e) {
// log error
_egw_log_exception($e);
}
} }
/** /**

View File

@ -31,25 +31,25 @@ class Backup
* *
* @var Api\Db\Schema * @var Api\Db\Schema
*/ */
var $schema_proc; protected $schema_proc;
/** /**
* Reference to ADOdb (connection) object * Reference to ADOdb (connection) object
* *
* @var ADOConnection * @var ADOConnection
*/ */
var $adodb; protected $adodb;
/** /**
* DB schemas, as array tablename => schema * DB schemas, as array tablename => schema
* *
* @var array * @var array
*/ */
var $schemas = array(); protected $schemas = array();
/** /**
* Tables to exclude from the backup: sessions, diverse caches which get automatic rebuild * Tables to exclude from the backup: sessions, diverse caches which get automatic rebuild
* *
* @var array * @var array
*/ */
var $exclude_tables = array( public $exclude_tables = array(
'egw_sessions','egw_app_sessions','phpgw_sessions','phpgw_app_sessions', // eGW's session-tables 'egw_sessions','egw_app_sessions','phpgw_sessions','phpgw_app_sessions', // eGW's session-tables
'phpgw_anglemail', // email's cache 'phpgw_anglemail', // email's cache
'egw_felamimail_cache','egw_felamimail_folderstatus','phpgw_felamimail_cache','phpgw_felamimail_folderstatus', // felamimail's cache 'egw_felamimail_cache','egw_felamimail_folderstatus','phpgw_felamimail_cache','phpgw_felamimail_folderstatus', // felamimail's cache
@ -60,13 +60,13 @@ class Backup
* *
* @var string|boolean * @var string|boolean
*/ */
var $system_tables = false; public $system_tables = false;
/** /**
* Regular expression to identify eGW tables => if set only they are used * Regular expression to identify eGW tables => if set only they are used
* *
* @var string|boolean * @var string|boolean
*/ */
var $egw_tables = false; public $egw_tables = false;
/** /**
* Regular expression to identify a Guacamole table OR view * Regular expression to identify a Guacamole table OR view
*/ */
@ -76,25 +76,25 @@ class Backup
* *
* @var string * @var string
*/ */
var $backup_dir; public $backup_dir;
/** /**
* Minimum number of backup files to keep. Zero for: Disable cleanup. * Minimum number of backup files to keep. Zero for: Disable cleanup.
* *
* @var integer * @var integer
*/ */
var $backup_mincount; public $backup_mincount;
/** /**
* Backup Files config value, will be overwritten by the availability of the ZibArchive libraries * Backup Files config value, will be overwritten by the availability of the ZibArchive libraries
* *
* @var boolean * @var boolean
*/ */
var $backup_files = false ; public $backup_files = false ;
/** /**
* Reference to schema_proc's Api\Db object * Reference to schema_proc's Api\Db object
* *
* @var Api\Db * @var Api\Db
*/ */
var $db; protected $db;
/** /**
* Constructor * Constructor
@ -179,65 +179,66 @@ class Backup
/** /**
* Opens the backup-file using the highest available compression * Opens the backup-file using the highest available compression
* *
* @param $name =false string/boolean filename to use, or false for the default one * @param ?string $name =false string/boolean filename to use, or false for the default one
* @param $reading =false opening for reading ('rb') or writing ('wb') * @param bool $reading =false opening for reading ('rb') or writing ('wb')
* @return string/resource/zip error-msg of file-handle * @param bool $un_compress true: opening file with (un)compression wrapper, false: opening as it is for download
* @return resource file-handle
* @throws \Exception on error
*/ */
function fopen_backup($name=false,$reading=false) public function fopen_backup(string $name=null, bool $reading=false, bool $un_compress=true)
{ {
//echo "function fopen_backup($name,$reading)<br>"; // ! if ($name)
{
$name = $this->backup_dir.'/'.basename($name);
}
if (!$name) if (!$name)
{ {
//echo '-> !$name<br>'; // !
if (empty($this->backup_dir) || !is_writable($this->backup_dir)) if (empty($this->backup_dir) || !is_writable($this->backup_dir))
{ {
$this->log($name, $reading, null, lang("backupdir '%1' is not writeable by the webserver", $this->backup_dir)); $this->log($name, $reading, null, lang("backupdir '%1' is not writeable by the webserver", $this->backup_dir));
return lang("backupdir '%1' is not writeable by the webserver",$this->backup_dir); throw new Exception(lang("backupdir '%1' is not writeable by the webserver", $this->backup_dir));
} }
$name = $this->backup_dir.'/db_backup-'.date('YmdHi'); $name = $this->backup_dir.'/db_backup-'.date('YmdHi');
} }
else // remove the extension, to use the correct wrapper based on the extension // remove the extension, to use the correct wrapper based on the extension
elseif ($un_compress)
{ {
//echo '-> else<br>'; // !
$name = preg_replace('/\.(bz2|gz)$/i','',$name); $name = preg_replace('/\.(bz2|gz)$/i','',$name);
} }
$mode = $reading ? 'rb' : 'wb'; $mode = $reading ? 'rb' : 'wb';
list( , $type) = explode('.', basename($name)); $lang_mode = $reading ? lang("reading") : lang("writing");
if($type == 'zip' && $reading && $this->backup_files) [, $type] = explode('.', basename($name));
if($un_compress && $type == 'zip' && $reading && $this->backup_files)
{ {
//echo '-> $type == "zip" && $reading<br>'; // ! //echo '-> $type == "zip" && $reading<br>'; // !
if(!class_exists('ZipArchive', false)) if(!class_exists('ZipArchive', false))
{ {
$this->backup_files = false; $this->backup_files = false;
$this->log($name, $reading, null, lang("Cant open %1, needs ZipArchive", $name)); $this->log($name, $reading, null, lang("Cant open %1, needs ZipArchive", $name));
return lang("Cant open %1, needs ZipArchive", $name)."<br>\n"; throw new \Exception(lang("Cant open %1, needs ZipArchive", $name));
} }
if(!($f = fopen($path=$name, $mode))) if(!($f = fopen($path=$name, $mode)))
{ {
//echo ' -> !($f = fopen($name, $mode))<br>'; // !
$lang_mode = $reading ? lang("reading") : lang("writing");
$this->log($name, $reading, null, lang("Cant open '%1' for %2", $name, $lang_mode)); $this->log($name, $reading, null, lang("Cant open '%1' for %2", $name, $lang_mode));
return lang("Cant open '%1' for %2", $name, $lang_mode)."<br>"; throw new \Exception(lang("Cant open '%1' for %2", $name, $lang_mode));
} }
} }
elseif (class_exists('ZipArchive', false) && !$reading && $this->backup_files) elseif ($un_compress && class_exists('ZipArchive', false) && !$reading && $this->backup_files)
{ {
//echo '-> (new ZipArchive) != NULL && !$reading; '.$name.'<br>'; // ! //echo '-> (new ZipArchive) != NULL && !$reading; '.$name.'<br>'; // !
if (!($f = fopen($path=$name, $mode))) if (!($f = fopen($path=$name, $mode)))
{ {
$lang_mode = $reading ? lang("reading") : lang("writing");
$this->log($name, $reading, null, lang("Cant open '%1' for %2", $name, $lang_mode)); $this->log($name, $reading, null, lang("Cant open '%1' for %2", $name, $lang_mode));
return lang("Cant open '%1' for %2", $name, $lang_mode)."<br>"; throw new \Exception(lang("Cant open '%1' for %2", $name, $lang_mode));
} }
} }
elseif (!($f = fopen('compress.bzip2://'.($path=$name.'.bz2'), $mode)) && elseif (!($un_compress && (
!($f = fopen('compress.zlib://'.($path=$name.'.gz'),$mode)) && ($f = fopen('compress.bzip2://'.($path=$name.'.bz2'), $mode)) ||
!($f = fopen($path=$name,$mode)) ($f = fopen('compress.zlib://'.($path=$name.'.gz'),$mode)))) &&
) !($f = fopen($path=$name,$mode)))
{ {
$lang_mode = $reading ? lang("reading") : lang("writing");
$this->log($name, $reading, null, lang("Cant open '%1' for %2", $name, $lang_mode)); $this->log($name, $reading, null, lang("Cant open '%1' for %2", $name, $lang_mode));
return lang("Cant open '%1' for %2", $name, $lang_mode)."<br>"; throw new \Exception(lang("Cant open '%1' for %2", $name, $lang_mode));
} }
// Log start of backup/restore // Log start of backup/restore
@ -1228,6 +1229,89 @@ class Backup
error_log("Could NOT open ".self::LOG_FILE.': '.$msg); error_log("Could NOT open ".self::LOG_FILE.': '.$msg);
} }
} }
/**
* Move uploaded file to backup-directory
*
* @param array $file values for keys "tmp_name", "name", "size"
* @return ?string success message or null on error
*/
public function upload(array $file) : ?string
{
if (move_uploaded_file($file['tmp_name'], $filename = $this->backup_dir . '/' . basename($file['name'])))
{
$msg = lang("succesfully uploaded file %1", $filename . ', ' .
sprintf('%3.1f MB (%d)', $file['size'] / (1024 * 1024), $file['size'])) .
', md5=' . md5_file($file['tmp_name']) . ', sha1=' . sha1_file($file['tmp_name']);
$this->log($filename, $msg);
}
return $msg ?? null;
}
/**
* Delete a backup
*
* @param string $name filename
* @return string|null success message or null on error
*/
public function delete(string $name) : ?string
{
if (unlink($file = $this->backup_dir.'/'.basename($name))) // basename to not allow to change the dir
{
$this->log($file, $msg = lang("backup '%1' deleted", $file));
}
return $msg ?? null;
}
/**
* Rename a backup
* @param string $file
* @param string $new_name
* @return string|null success message or null on error
*/
public function rename(string $file, string $new_name) : ?string
{
[$ending] = array_reverse(explode('.', $file));
[$new_ending, $has_ending] = array_reverse(explode('.', $new_name));
if (!$has_ending || $new_ending != $ending)
{
$new_name .= '.' . $ending;
}
$file = $this->backup_dir.'/'.basename($file); // basename to not allow to change the dir
$ext = preg_match('/(\.gz|\.bz2)+$/i',$file,$matches) ? $matches[1] : '';
$new_file = $this->backup_dir.'/'.preg_replace('/(\.gz|\.bz2)+$/i','',basename($new_name)).$ext;
if (rename($file, $new_file))
{
$this->log($new_file, $msg=lang("backup '%1' renamed to '%2'", basename($file), basename($new_file)));
}
return $msg ?? null;
}
/**
* List available backups for restore
*
* @return array filename => [$ctime, $size] pairs
*/
public function index() : array
{
$files = [];
if (($handle = opendir($this->backup_dir)))
{
while(($file = readdir($handle)))
{
if ($file != '.' && $file != '..')
{
$files[$file] = [
'ctime' => filectime($this->backup_dir.'/'.$file),
'size' => filesize($this->backup_dir.'/'.$file)
];
}
}
closedir($handle);
}
arsort($files);
return $files;
}
} }
/* /*

View File

@ -13,6 +13,7 @@ use EGroupware\Api;
use EGroupware\Api\Framework; use EGroupware\Api\Framework;
use EGroupware\Api\Egw; use EGroupware\Api\Egw;
use EGroupware\Api\Vfs; use EGroupware\Api\Vfs;
use EGroupware\Stylite\Vfs\S3;
if (!is_object(@$GLOBALS['egw'])) // called from outside EGw ==> setup if (!is_object(@$GLOBALS['egw'])) // called from outside EGw ==> setup
{ {
@ -34,23 +35,31 @@ if (!is_object(@$GLOBALS['egw'])) // called from outside EGw ==> setup
$self = 'db_backup.php'; $self = 'db_backup.php';
$is_setup = true; $is_setup = true;
} }
$db_backup = new Api\Db\Backup(); if (class_exists(S3\Backup::class) && S3\Backup::available())
{
$db_backup = new S3\Backup();
}
else
{
$db_backup = new Api\Db\Backup();
}
$asyncservice = new Api\Asyncservice(); $asyncservice = new Api\Asyncservice();
// download a backup, has to be before any output !!! // download a backup, has to be before any output !!!
if (!empty($_POST['download'])) if (!empty($_POST['download']))
{ {
$file = key($_POST['download']); $filename = $db_backup->backup_dir.'/'.key($_POST['download']);
$file = $db_backup->backup_dir.'/'.basename($file); // basename to not allow to change the dir $file = $db_backup->fopen_backup($filename, true, false);
// FIRST: switch off zlib.output_compression, as this would limit downloads in size to memory_limit // FIRST: switch off zlib.output_compression, as this would limit downloads in size to memory_limit
ini_set('zlib.output_compression',0); ini_set('zlib.output_compression',0);
// SECOND: end all active output buffering // SECOND: end all active output buffering
while(ob_end_clean()) {} while(ob_end_clean()) {}
Api\Header\Content::type(basename($file)); Api\Header\Content::type(basename($filename));
readfile($file); fpassthru($file);
$db_backup->log($file, 'Downloaded'); fclose($file);
$db_backup->log($filename, 'Downloaded');
exit; exit;
} }
$setup_tpl = new Framework\Template($tpl_root); $setup_tpl = new Framework\Template($tpl_root);
@ -100,7 +109,7 @@ if (!empty($_POST['save_backup_settings']))
$minCount = 0; $minCount = 0;
$setup_tpl->set_var('error_msg',htmlspecialchars(lang("'%1' must be integer", lang("backup min count")))); $setup_tpl->set_var('error_msg',htmlspecialchars(lang("'%1' must be integer", lang("backup min count"))));
} }
$db_backup->saveConfig($minCount,$is_setup ? (boolean)$filesBackup : null); $db_backup->saveConfig($minCount,!empty($is_setup) ? $filesBackup : null);
if (is_int($minCount) && $minCount > 0) if (is_int($minCount) && $minCount > 0)
{ {
@ -126,12 +135,14 @@ if (!empty($_POST['mount']))
// create a backup now // create a backup now
if (!empty($_POST['backup'])) if (!empty($_POST['backup']))
{ {
if (is_resource($f = $db_backup->fopen_backup())) try {
{ $f = $db_backup->fopen_backup();
$starttime = microtime(true); $starttime = microtime(true);
$db_backup->backup($f); $db_backup->backup($f);
if(is_resource($f)) if(is_resource($f))
{
fclose($f); fclose($f);
}
$setup_tpl->set_var('error_msg', lang('backup finished').': '. number_format(microtime(true)-$starttime, 1).'s'); $setup_tpl->set_var('error_msg', lang('backup finished').': '. number_format(microtime(true)-$starttime, 1).'s');
/* Remove old backups. */ /* Remove old backups. */
@ -142,9 +153,8 @@ if (!empty($_POST['backup']))
echo '<div align="center">'.lang('entry has been deleted sucessfully').': '.$file."</div>\n"; echo '<div align="center">'.lang('entry has been deleted sucessfully').': '.$file."</div>\n";
} }
} }
else catch (\Exception $e) {
{ $setup_tpl->set_var('error_msg', $e->getMessage());
$setup_tpl->set_var('error_msg',$f);
} }
} }
$setup_tpl->set_var('backup_now_button','<input type="submit" name="backup" title="'. $setup_tpl->set_var('backup_now_button','<input type="submit" name="backup" title="'.
@ -161,43 +171,20 @@ $setup_tpl->set_var('backup_save_settings','<input type="submit" name="save_back
$setup_tpl->set_var('backup_mount','<input type="submit" name="mount" value="'.htmlspecialchars(lang('Mount backup directory to %1','/backup')).'" />'); $setup_tpl->set_var('backup_mount','<input type="submit" name="mount" value="'.htmlspecialchars(lang('Mount backup directory to %1','/backup')).'" />');
if (!empty($_POST['upload']) && is_array($_FILES['uploaded']) && !$_FILES['uploaded']['error'] && if (!empty($_POST['upload']) && is_array($_FILES['uploaded']) && !$_FILES['uploaded']['error'] &&
is_uploaded_file($_FILES['uploaded']['tmp_name'])) is_uploaded_file($_FILES['uploaded']['tmp_name']) && ($msg = $db_backup->upload($_FILES['uploaded'])))
{ {
move_uploaded_file($_FILES['uploaded']['tmp_name'], $filename=$db_backup->backup_dir.'/'.$_FILES['uploaded']['name']); $setup_tpl->set_var('error_msg', $msg);
$md5 = ', md5='.md5_file($filename).', sha1='.sha1_file($filename);
$setup_tpl->set_var('error_msg', ($msg=lang("succesfully uploaded file %1", $filename.', '.
sprintf('%3.1f MB (%d)',$_FILES['uploaded']['size']/(1024*1024),$_FILES['uploaded']['size']))).$md5);
$db_backup->log($filename, $msg);
} }
// delete a backup // delete a backup
if (!empty($_POST['delete'])) if (!empty($_POST['delete']) && ($msg = $db_backup->delete(key($_POST['delete']))))
{ {
$file = $db_backup->backup_dir.'/'.basename(key($_POST['delete'])); // basename to not allow to change the dir $setup_tpl->set_var('error_msg', $msg);
$db_backup->log($file, lang("backup '%1' deleted", $file));
if (unlink($file)) $setup_tpl->set_var('error_msg',lang("backup '%1' deleted",$file));
} }
// rename a backup // rename a backup
if (!empty($_POST['rename'])) if (!empty($file=$_POST['rename']) && !empty($_POST['new_name'][$file]) &&
($msg = $db_backup->rename($file, $_POST['new_name'][$file])))
{ {
$file = key($_POST['rename']); $setup_tpl->set_var('error_msg', $msg);
$new_name = $_POST['new_name'][$file];
if (!empty($new_name))
{
list($ending) = array_reverse(explode('.', $file));
list($new_ending, $has_ending) = array_reverse(explode('.', $new_name));
if(!$has_ending || $new_ending != $ending) $new_name .= '.'.$ending;
$file = $db_backup->backup_dir.'/'.basename($file); // basename to not allow to change the dir
$ext = preg_match('/(\.gz|\.bz2)+$/i',$file,$matches) ? $matches[1] : '';
$new_file = $db_backup->backup_dir.'/'.preg_replace('/(\.gz|\.bz2)+$/i','',basename($new_name)).$ext;
if (rename($file,$new_file))
{
$setup_tpl->set_var('error_msg',lang("backup '%1' renamed to '%2'",basename($file),basename($new_file)));
$db_backup->log($new_file, lang("backup '%1' renamed to '%2'",basename($file),basename($new_file)));
}
}
} }
// restore a backup // restore a backup
if (!empty($_POST['restore'])) if (!empty($_POST['restore']))
@ -205,12 +192,12 @@ if (!empty($_POST['restore']))
$file = key($_POST['restore']); $file = key($_POST['restore']);
$file = $db_backup->backup_dir.'/'.basename($file); // basename to not allow to change the dir $file = $db_backup->backup_dir.'/'.basename($file); // basename to not allow to change the dir
if (is_resource($f = $db_backup->fopen_backup($file,true))) try {
{ $f = $db_backup->fopen_backup($file,true);
$start = time(); $start = time();
$db_backup->restore($f, true, $file); // allways convert to current system charset on restore $db_backup->restore($f, true, $file); // always convert to current system charset on restore
$setup_tpl->set_var('error_msg',lang("backup '%1' restored",$file).' ('.(time()-$start).' s)'); $setup_tpl->set_var('error_msg',lang("backup '%1' restored",$file).' ('.(time()-$start).' s)');
if ($run_in_egw) if (isset($run_in_egw))
{ {
// updating the backup // updating the backup
$cmd = new setup_cmd_update($GLOBALS['egw']->session->account_domain, $cmd = new setup_cmd_update($GLOBALS['egw']->session->account_domain,
@ -220,9 +207,9 @@ if (!empty($_POST['restore']))
echo '<h3>'.lang('You should %1log out%2 and in again, to update your current session!','<a href="'.Egw::link('/logout.php').'" target="_parent">','</a>')."</h3>\n"; echo '<h3>'.lang('You should %1log out%2 and in again, to update your current session!','<a href="'.Egw::link('/logout.php').'" target="_parent">','</a>')."</h3>\n";
} }
} }
else catch (\Exception $e)
{ {
$setup_tpl->set_var('error_msg',$f); $setup_tpl->set_var('error_msg', $e->getMessage());
} }
} }
// create a new scheduled backup // create a new scheduled backup
@ -256,28 +243,16 @@ $setup_tpl->set_var('next_run','&nbsp;');
$setup_tpl->set_var('actions','<input type="submit" name="schedule" value="'.htmlspecialchars(lang('schedule')).'" />'); $setup_tpl->set_var('actions','<input type="submit" name="schedule" value="'.htmlspecialchars(lang('schedule')).'" />');
$setup_tpl->parse('schedule_rows','schedule_row',true); $setup_tpl->parse('schedule_rows','schedule_row',true);
// listing the availible backup sets // listing the available backups
$setup_tpl->set_var('backup_dir',$db_backup->backup_dir); $setup_tpl->set_var('backup_dir',$db_backup->backup_dir);
$setup_tpl->set_var('set_rows',''); $setup_tpl->set_var('set_rows','');
$handle = @opendir($db_backup->backup_dir);
$files = array();
while($handle && ($file = readdir($handle)))
{
if ($file != '.' && $file != '..')
{
$files[$file] = filectime($db_backup->backup_dir.'/'.$file);
}
}
if ($handle) closedir($handle);
arsort($files); foreach($db_backup->index() as $file => $attrs)
foreach($files as $file => $ctime)
{ {
$size = filesize($db_backup->backup_dir.'/'.$file);
$setup_tpl->set_var(array( $setup_tpl->set_var(array(
'filename' => $file, 'filename' => $file,
'mod' => date('Y-m-d H:i',$ctime), 'mod' => date('Y-m-d H:i', $attrs['ctime']),
'size' => sprintf('%3.1f MB (%d)',$size/(1024*1024),$size), 'size' => sprintf('%3.1f MB (%d)',$attrs['size']/(1024*1024), $attrs['size']),
'actions' => '<input type="submit" name="download['.$file.']" value="'.htmlspecialchars(lang('download')).'" />&nbsp;'."\n". 'actions' => '<input type="submit" name="download['.$file.']" value="'.htmlspecialchars(lang('download')).'" />&nbsp;'."\n".
($file === Api\Db\Backup::LOG_FILE ? '' : ($file === Api\Db\Backup::LOG_FILE ? '' :
'<input type="submit" name="delete['.$file.']" value="'.htmlspecialchars(lang('delete')).'" onclick="return confirm(\''. '<input type="submit" name="delete['.$file.']" value="'.htmlspecialchars(lang('delete')).'" onclick="return confirm(\''.
@ -315,7 +290,7 @@ $setup_tpl->set_var(array(
$setup_tpl->set_var('self',$self); $setup_tpl->set_var('self',$self);
$setup_tpl->pparse('out','T_db_backup'); $setup_tpl->pparse('out','T_db_backup');
if ($run_in_egw) if (isset($run_in_egw))
{ {
echo $GLOBALS['egw']->framework->footer(); echo $GLOBALS['egw']->framework->footer();
} }