From ddc971567f951f4a38158b3939588bd32c1edff4 Mon Sep 17 00:00:00 2001 From: Marek Miklewicz Date: Sat, 4 Jul 2026 16:00:04 +0200 Subject: [PATCH] Import postgresql plugin --- .gitignore | 6 + admin/index.html | 5 + assets/adminer/README.md | 14 + assets/adminer/SHA256SUMS | 2 + assets/adminer/adminer-5.4.2-pgsql.php | 1079 ++++++++++++++++ assets/adminer/adminer-extension.php | 101 ++ exec/bootstrap.php | 156 +++ exec/handlers/assign_database.php | 88 ++ exec/handlers/change_password.php | 586 +++++++++ exec/handlers/create_database.php | 19 + exec/handlers/create_user.php | 137 ++ exec/handlers/delete_databases.php | 171 +++ exec/handlers/delete_users.php | 158 +++ exec/handlers/download.php | 111 ++ exec/handlers/file_picker.php | 18 + exec/handlers/index.php | 788 ++++++++++++ exec/handlers/manage_hosts.php | 224 ++++ exec/handlers/modify_privileges.php | 216 ++++ exec/handlers/open_adminer.php | 82 ++ exec/handlers/upload_backup.php | 1290 +++++++++++++++++++ exec/lib/AdminerRuntimeConfig.php | 61 + exec/lib/AdminerSso.php | 326 +++++ exec/lib/AppContext.php | 1208 ++++++++++++++++++ exec/lib/BackupQueueService.php | 1618 ++++++++++++++++++++++++ exec/lib/CsrfGuard.php | 70 + exec/lib/DirectAdminUser.php | 296 +++++ exec/lib/FilePicker.php | 536 ++++++++ exec/lib/Http.php | 128 ++ exec/lib/Lang.php | 78 ++ exec/lib/LocalizedException.php | 30 + exec/lib/NamePolicy.php | 229 ++++ exec/lib/PostgresService.php | 1065 ++++++++++++++++ exec/lib/Settings.php | 365 ++++++ hooks/user_img.html | 1 + hooks/user_txt.html | 1 + images/postgresql.svg | 16 + images/user_icon.svg | 16 + lang/en.php | 462 +++++++ lang/pl.php | 50 + php.ini | 10 + plugin-settings.conf | 50 + plugin.conf | 8 + scripts/backup_job.sh | 343 +++++ scripts/install.sh | 156 +++ scripts/restore_job.sh | 578 +++++++++ scripts/setup/adminer_install.sh | 704 +++++++++++ scripts/setup/dbrestore.sh | 127 ++ scripts/setup/pg_hba_sync.sh | 636 ++++++++++ scripts/setup/postgres_backup.sh | 226 ++++ scripts/setup/postgresql_install.sh | 786 ++++++++++++ scripts/setup/postgresql_upgrade.sh | 1371 ++++++++++++++++++++ scripts/setup/recompile_php.sh | 520 ++++++++ scripts/setup/restore_all.sh | 211 +++ scripts/uninstall.sh | 30 + scripts/update.sh | 3 + scripts/worker.sh | 104 ++ user/assign_database.html | 6 + user/change_password.html | 6 + user/create_database.html | 6 + user/create_user.html | 6 + user/delete_databases.html | 6 + user/delete_users.html | 6 + user/download.html | 6 + user/download.php | 6 + user/download.raw | 7 + user/enhanced.css | 483 +++++++ user/evolution.css | 483 +++++++ user/file_picker.html | 6 + user/file_picker.raw | 7 + user/index.html | 6 + user/manage_hosts.html | 6 + user/modify_privileges.html | 6 + user/open_adminer.raw | 7 + user/upload_backup.html | 6 + 74 files changed, 18735 insertions(+) create mode 100644 .gitignore create mode 100755 admin/index.html create mode 100644 assets/adminer/README.md create mode 100644 assets/adminer/SHA256SUMS create mode 100644 assets/adminer/adminer-5.4.2-pgsql.php create mode 100644 assets/adminer/adminer-extension.php create mode 100644 exec/bootstrap.php create mode 100644 exec/handlers/assign_database.php create mode 100644 exec/handlers/change_password.php create mode 100644 exec/handlers/create_database.php create mode 100644 exec/handlers/create_user.php create mode 100644 exec/handlers/delete_databases.php create mode 100644 exec/handlers/delete_users.php create mode 100644 exec/handlers/download.php create mode 100644 exec/handlers/file_picker.php create mode 100644 exec/handlers/index.php create mode 100644 exec/handlers/manage_hosts.php create mode 100644 exec/handlers/modify_privileges.php create mode 100644 exec/handlers/open_adminer.php create mode 100644 exec/handlers/upload_backup.php create mode 100644 exec/lib/AdminerRuntimeConfig.php create mode 100644 exec/lib/AdminerSso.php create mode 100644 exec/lib/AppContext.php create mode 100644 exec/lib/BackupQueueService.php create mode 100644 exec/lib/CsrfGuard.php create mode 100644 exec/lib/DirectAdminUser.php create mode 100644 exec/lib/FilePicker.php create mode 100644 exec/lib/Http.php create mode 100644 exec/lib/Lang.php create mode 100644 exec/lib/LocalizedException.php create mode 100644 exec/lib/NamePolicy.php create mode 100644 exec/lib/PostgresService.php create mode 100644 exec/lib/Settings.php create mode 100644 hooks/user_img.html create mode 100644 hooks/user_txt.html create mode 100644 images/postgresql.svg create mode 100644 images/user_icon.svg create mode 100644 lang/en.php create mode 100644 lang/pl.php create mode 100644 php.ini create mode 100644 plugin-settings.conf create mode 100644 plugin.conf create mode 100644 scripts/backup_job.sh create mode 100755 scripts/install.sh create mode 100644 scripts/restore_job.sh create mode 100755 scripts/setup/adminer_install.sh create mode 100755 scripts/setup/dbrestore.sh create mode 100755 scripts/setup/pg_hba_sync.sh create mode 100755 scripts/setup/postgres_backup.sh create mode 100755 scripts/setup/postgresql_install.sh create mode 100755 scripts/setup/postgresql_upgrade.sh create mode 100755 scripts/setup/recompile_php.sh create mode 100755 scripts/setup/restore_all.sh create mode 100755 scripts/uninstall.sh create mode 100755 scripts/update.sh create mode 100644 scripts/worker.sh create mode 100755 user/assign_database.html create mode 100755 user/change_password.html create mode 100755 user/create_database.html create mode 100755 user/create_user.html create mode 100755 user/delete_databases.html create mode 100755 user/delete_users.html create mode 100644 user/download.html create mode 100644 user/download.php create mode 100644 user/download.raw create mode 100644 user/enhanced.css create mode 100644 user/evolution.css create mode 100644 user/file_picker.html create mode 100644 user/file_picker.raw create mode 100755 user/index.html create mode 100755 user/manage_hosts.html create mode 100755 user/modify_privileges.html create mode 100755 user/open_adminer.raw create mode 100644 user/upload_backup.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb777f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +._* +__MACOSX/ +.claude/ +error.log +data/* diff --git a/admin/index.html b/admin/index.html new file mode 100755 index 0000000..2a42efc --- /dev/null +++ b/admin/index.html @@ -0,0 +1,5 @@ +#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/da-postgresql/php.ini +credentials();$K=Driver::connect($xb[0],$xb[1],$xb[2]);return(is_object($K)?$K:null);}function +idf_unescape($u){if(!preg_match('~^[`\'"[]~',$u))return$u;$pe=substr($u,-1);return +str_replace($pe.$pe,$pe,substr($u,1,-1));}function +q($yh){return +connection()->quote($yh);}function +escape_string($X){return +substr(q($X),1,-1);}function +idx($ua,$y,$Jb=null){return($ua&&array_key_exists($y,$ua)?$ua[$y]:$Jb);}function +number($X){return +preg_replace('~[^0-9]+~','',$X);}function +number_type(){return'((?$X)$K[stripslashes($y)]=(is_array($X)?remove_slashes($X,$Mc):($Mc?$X:stripslashes($X)));return$K;}function +bracket_escape($u,$_a=false){static$ji=array(':'=>':1',']'=>':2','['=>':3','"'=>':4');return +strtr($u,($_a?array_flip($ji):$ji));}function +min_version($Ri,$Be="",$g=null){$g=connection($g);$fh=$g->server_info;if($Be&&preg_match('~([\d.]+)-MariaDB~',$fh,$B)){$fh=$B[1];$Ri=$Be;}return$Ri&&version_compare($fh,$Ri)>=0;}function +charset(Db$f){return(min_version("5.5.3",0,$f)?"utf8mb4":"utf8");}function +ini_bool($Sd){$X=ini_get($Sd);return(preg_match('~^(on|true|yes)$~i',$X)||(int)$X);}function +ini_bytes($Sd){$X=ini_get($Sd);switch(strtolower(substr($X,-1))){case'g':$X=(int)$X*1024;case'm':$X=(int)$X*1024;case'k':$X=(int)$X*1024;}return$X;}function +sid(){static$K;if($K===null)$K=(SID&&!($_COOKIE&&ini_bool("session.use_cookies")));return$K;}function +set_password($Qi,$O,$V,$G){$_SESSION["pwds"][$Qi][$O][$V]=($_COOKIE["adminer_key"]&&is_string($G)?array(encrypt_string($G,$_COOKIE["adminer_key"])):$G);}function +get_password(){$K=get_session("pwds");if(is_array($K))$K=($_COOKIE["adminer_key"]?decrypt_string($K[0],$_COOKIE["adminer_key"]):false);return$K;}function +get_val($I,$l=0,$nb=null){$nb=connection($nb);$J=$nb->query($I);if(!is_object($J))return +false;$L=$J->fetch_row();return($L?$L[$l]:false);}function +get_vals($I,$c=0){$K=array();$J=connection()->query($I);if(is_object($J)){while($L=$J->fetch_row())$K[]=$L[$c];}return$K;}function +get_key_vals($I,$g=null,$ih=true){$g=connection($g);$K=array();$J=$g->query($I);if(is_object($J)){while($L=$J->fetch_row()){if($ih)$K[$L[0]]=$L[1];else$K[]=$L[0];}}return$K;}function +get_rows($I,$g=null,$k="

"){$nb=connection($g);$K=array();$J=$nb->query($I);if(is_object($J)){while($L=$J->fetch_assoc())$K[]=$L;}elseif(!$J&&!$g&&$k&&(defined('Adminer\PAGE_HEADER')||$k=="-- "))echo$k.error()."\n";return$K;}function +unique_array($L,array$w){foreach($w +as$v){if(preg_match("~PRIMARY|UNIQUE~",$v["type"])&&!$v["partial"]){$K=array();foreach($v["columns"]as$y){if(!isset($L[$y]))continue +2;$K[$y]=$L[$y];}return$K;}}}function +escape_key($y){if(preg_match('(^([\w(]+)('.str_replace("_",".*",preg_quote(idf_escape("_"))).')([ \w)]+)$)',$y,$B))return$B[1].idf_escape(idf_unescape($B[2])).$B[3];return +idf_escape($y);}function +where(array$Z,array$m=array()){$K=array();foreach((array)$Z["where"]as$y=>$X){$y=bracket_escape($y,true);$c=escape_key($y);$l=idx($m,$y,array());$Jc=$l["type"];$K[]=$c.(JUSH=="sql"&&$Jc=="json"?" = CAST(".q($X)." AS JSON)":(JUSH=="pgsql"&&preg_match('~^json~',$Jc)?"::jsonb = ".q($X)."::jsonb":(JUSH=="sql"&&is_numeric($X)&&preg_match('~\.~',$X)?" LIKE ".q($X):(JUSH=="mssql"&&strpos($Jc,"datetime")===false?" LIKE ".q(preg_replace('~[_%[]~','[\0]',$X)):" = ".unconvert_field($l,q($X))))));if(JUSH=="sql"&&preg_match('~char|text~',$Jc)&&preg_match("~[^ -@]~",$X))$K[]="$c = ".q($X)." COLLATE ".charset(connection())."_bin";}foreach((array)$Z["null"]as$y)$K[]=escape_key($y)." IS NULL";return +implode(" AND ",$K);}function +where_check($X,array$m=array()){parse_str($X,$Qa);remove_slashes(array(&$Qa));return +where($Qa,$m);}function +where_link($s,$c,$Y,$sf="="){return"&where%5B$s%5D%5Bcol%5D=".urlencode($c)."&where%5B$s%5D%5Bop%5D=".urlencode(($Y!==null?$sf:"IS NULL"))."&where%5B$s%5D%5Bval%5D=".urlencode($Y);}function +convert_fields(array$d,array$m,array$N=array()){$K="";foreach($d +as$y=>$X){if($N&&!in_array(idf_escape($y),$N))continue;$va=convert_field($m[$y]);if($va)$K +.=", $va AS ".idf_escape($y);}return$K;}function +cookie($D,$Y,$xe=2592000){header("Set-Cookie: $D=".rawurlencode($Y).($xe?"; expires=".gmdate("D, d M Y H:i:s",time()+$xe)." GMT":"")."; path=".preg_replace('~\?.*~','',$_SERVER["REQUEST_URI"]).(HTTPS?"; secure":"")."; HttpOnly; SameSite=lax",false);}function +get_settings($ub){parse_str($_COOKIE[$ub],$jh);return$jh;}function +get_setting($y,$ub="adminer_settings",$Jb=null){return +idx(get_settings($ub),$y,$Jb);}function +save_settings(array$jh,$ub="adminer_settings"){$Y=http_build_query($jh+get_settings($ub));cookie($ub,$Y);$_COOKIE[$ub]=$Y;}function +restart_session(){if(!ini_bool("session.use_cookies")&&(!function_exists('session_status')||session_status()==1))session_start();}function +stop_session($Uc=false){$Hi=ini_bool("session.use_cookies");if(!$Hi||$Uc){session_write_close();if($Hi&&@ini_set("session.use_cookies",'0')===false)session_start();}}function&get_session($y){return$_SESSION[$y][DRIVER][SERVER][$_GET["username"]];}function +set_session($y,$X){$_SESSION[$y][DRIVER][SERVER][$_GET["username"]]=$X;}function +auth_url($Qi,$O,$V,$j=null){$Di=remove_from_uri(implode("|",array_keys(SqlDriver::$drivers))."|username|ext|".($j!==null?"db|":"").($Qi=='mssql'||$Qi=='pgsql'?"":"ns|").session_name());preg_match('~([^?]*)\??(.*)~',$Di,$B);return"$B[1]?".(sid()?SID."&":"").($Qi!="server"||$O!=""?urlencode($Qi)."=".urlencode($O)."&":"").($_GET["ext"]?"ext=".urlencode($_GET["ext"])."&":"")."username=".urlencode($V).($j!=""?"&db=".urlencode($j):"").($B[2]?"&$B[2]":"");}function +is_ajax(){return($_SERVER["HTTP_X_REQUESTED_WITH"]=="XMLHttpRequest");}function +redirect($A,$C=null){if($C!==null){restart_session();$_SESSION["messages"][preg_replace('~^[^?]*~','',($A!==null?$A:$_SERVER["REQUEST_URI"]))][]=$C;}if($A!==null){if($A=="")$A=".";header("Location: $A");exit;}}function +query_redirect($I,$A,$C,$yg=true,$xc=true,$Ec=false,$Wh=""){if($xc){$wh=microtime(true);$Ec=!connection()->query($I);$Wh=format_time($wh);}$sh=($I?adminer()->messageQuery($I,$Wh,$Ec):"");if($Ec){adminer()->error +.=error().$sh.script("messagesPrint();")."
";return +false;}if($yg)redirect($A,$C.$sh);return +true;}class +Queries{static$queries=array();static$start=0;}function +queries($I){if(!Queries::$start)Queries::$start=microtime(true);Queries::$queries[]=(driver()->delimiter!=';'?$I:(preg_match('~;$~',$I)?"DELIMITER ;;\n$I;\nDELIMITER ":$I).";");return +connection()->query($I);}function +apply_queries($I,array$T,$uc='Adminer\table'){foreach($T +as$R){if(!queries("$I ".$uc($R)))return +false;}return +true;}function +queries_redirect($A,$C,$yg){$tg=implode("\n",Queries::$queries);$Wh=format_time(Queries::$start);return +query_redirect($tg,$A,$C,$yg,false,!$yg,$Wh);}function +format_time($wh){return +lang(0,max(0,microtime(true)-$wh));}function +relative_uri(){return +str_replace(":","%3a",preg_replace('~^[^?]*/([^?]*)~','\1',$_SERVER["REQUEST_URI"]));}function +remove_from_uri($Lf=""){return +substr(preg_replace("~(?<=[?&])($Lf".(SID?"":"|".session_name()).")=[^&]*&~",'',relative_uri()."&"),0,-1);}function +get_file($y,$Ib=false,$Ob=""){$Lc=$_FILES[$y];if(!$Lc)return +null;foreach($Lc +as$y=>$X)$Lc[$y]=(array)$X;$K='';foreach($Lc["error"]as$y=>$k){if($k)return$k;$D=$Lc["name"][$y];$ei=$Lc["tmp_name"][$y];$rb=file_get_contents($Ib&&preg_match('~\.gz$~',$D)?"compress.zlib://$ei":$ei);if($Ib){$wh=substr($rb,0,3);if(function_exists("iconv")&&preg_match("~^\xFE\xFF|^\xFF\xFE~",$wh))$rb=iconv("utf-16","utf-8",$rb);elseif($wh=="\xEF\xBB\xBF")$rb=substr($rb,3);}$K +.=$rb;if($Ob)$K +.=(preg_match("($Ob\\s*\$)",$rb)?"":$Ob)."\n\n";}return$K;}function +upload_error($k){$Ke=($k==UPLOAD_ERR_INI_SIZE?ini_get("upload_max_filesize"):0);return($k?lang(1).($Ke?" ".lang(2,$Ke):""):lang(3));}function +repeat_pattern($Yf,$ve){return +str_repeat("$Yf{0,65535}",$ve/65535)."$Yf{0,".($ve%65535)."}";}function +is_utf8($X){return(preg_match('~~u',$X)&&!preg_match('~[\0-\x8\xB\xC\xE-\x1F]~',$X));}function +format_number($X){return +strtr(number_format($X,0,".",lang(4)),preg_split('~~u',lang(5),-1,PREG_SPLIT_NO_EMPTY));}function +friendly_url($X){return +preg_replace('~\W~i','-',$X);}function +table_status1($R,$Fc=false){$K=table_status($R,$Fc);return($K?reset($K):array("Name"=>$R));}function +column_foreign_keys($R){$K=array();foreach(adminer()->foreignKeys($R)as$o){foreach($o["source"]as$X)$K[$X][]=$o;}return$K;}function +fields_from_edit(){$K=array();foreach((array)$_POST["field_keys"]as$y=>$X){if($X!=""){$X=bracket_escape($X);$_POST["function"][$X]=$_POST["field_funs"][$y];$_POST["fields"][$X]=$_POST["field_vals"][$y];}}foreach((array)$_POST["fields"]as$y=>$X){$D=bracket_escape($y,true);$K[$D]=array("field"=>$D,"privileges"=>array("insert"=>1,"update"=>1,"where"=>1,"order"=>1),"null"=>1,"auto_increment"=>($y==driver()->primary),);}return$K;}function +dump_headers($_d,$Ze=false){$K=adminer()->dumpHeaders($_d,$Ze);$If=$_POST["output"];if($If!="text")header("Content-Disposition: attachment; filename=".adminer()->dumpFilename($_d).".$K".($If!="file"&&preg_match('~^[0-9a-z]+$~',$If)?".$If":""));session_write_close();if(!ob_get_level())ob_start(null,4096);ob_flush();flush();return$K;}function +dump_csv(array$L){$ri=$_POST["format"]=="tsv";foreach($L +as$y=>$X){if(preg_match('~["\n]|^0[^.]|\.\d*0$|'.($ri?'\t':'[,;]|^$').'~',$X))$L[$y]='"'.str_replace('"','""',$X).'"';}echo +implode(($_POST["format"]=="csv"?",":($ri?"\t":";")),$L)."\r\n";}function +apply_sql_function($q,$c){return($q?($q=="unixepoch"?"DATETIME($c, '$q')":($q=="count distinct"?"COUNT(DISTINCT ":strtoupper("$q("))."$c)"):$c);}function +get_temp_dir(){$K=ini_get("upload_tmp_dir");if(!$K){if(function_exists('sys_get_temp_dir'))$K=sys_get_temp_dir();else{$n=@tempnam("","");if(!$n)return'';$K=dirname($n);unlink($n);}}return$K;}function +file_open_lock($n){if(is_link($n))return;$p=@fopen($n,"c+");if(!$p)return;@chmod($n,0660);if(!flock($p,LOCK_EX)){fclose($p);return;}return$p;}function +file_write_unlock($p,$Cb){rewind($p);fwrite($p,$Cb);ftruncate($p,strlen($Cb));file_unlock($p);}function +file_unlock($p){flock($p,LOCK_UN);fclose($p);}function +first(array$ua){return +reset($ua);}function +password_file($h){$n=get_temp_dir()."/adminer.key";if(!$h&&!file_exists($n))return'';$p=file_open_lock($n);if(!$p)return'';$K=stream_get_contents($p);if(!$K){$K=rand_string();file_write_unlock($p,$K);}else +file_unlock($p);return$K;}function +rand_string(){return +md5(uniqid(strval(mt_rand()),true));}function +select_value($X,$_,array$l,$Vh){if(is_array($X)){$K="";if(array_filter($X,'is_array')==array_values($X)){$ie=array();foreach($X +as$W)$ie+=array_fill_keys(array_keys($W),null);foreach(array_keys($ie)as$he)$K +.="".h($he);foreach($X +as$W){$K +.="";foreach(array_merge($ie,$W)as$Li)$K +.="".select_value($Li,$_,$l,$Vh);}}else{foreach($X +as$he=>$W)$K +.="".($X!=array_values($X)?"".h($he):"")."".select_value($W,$_,$l,$Vh);}return"$K
";}if(!$_)$_=adminer()->selectLink($X,$l);if($_===null){if(is_mail($X))$_="mailto:$X";if(is_url($X))$_=$X;}$K=adminer()->editVal(driver()->value($X,$l),$l);if($K!==null){if(!is_utf8($K))$K="\0";elseif($Vh!=""&&is_shortable($l))$K=shorten_utf8($K,max(0,+$Vh));else$K=h($K);}return +adminer()->selectVal($K,$_,$l,$X);}function +is_blob(array$l){return +preg_match('~blob|bytea|raw|file~',$l["type"])&&!in_array($l["type"],idx(driver()->structuredTypes(),lang(6),array()));}function +is_mail($hc){$wa='[-a-z0-9!#$%&\'*+/=?^_`{|}~]';$Wb='[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])';$Yf="$wa+(\\.$wa+)*@($Wb?\\.)+$Wb";return +is_string($hc)&&preg_match("(^$Yf(,\\s*$Yf)*\$)i",$hc);}function +is_url($yh){$Wb='[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])';return +preg_match("~^((https?):)?//($Wb?\\.)+$Wb(:\\d+)?(/.*)?(\\?.*)?(#.*)?\$~i",$yh);}function +is_shortable(array$l){return!preg_match('~'.number_type().'|date|time|year~',$l["type"]);}function +host_port($O){return(preg_match('~^(\[(.+)]|([^:]+)):([^:]+)$~',$O,$B)?array($B[2].$B[3],$B[4]):array($O,''));}function +count_rows($R,array$Z,$be,array$r){$I=" FROM ".table($R).($Z?" WHERE ".implode(" AND ",$Z):"");return($be&&(JUSH=="sql"||count($r)==1)?"SELECT COUNT(DISTINCT ".implode(", ",$r).")$I":"SELECT COUNT(*)".($be?" FROM (SELECT 1$I GROUP BY ".implode(", ",$r).") x":$I));}function +slow_query($I){$j=adminer()->database();$Xh=adminer()->queryTimeout();$nh=driver()->slowQuery($I,$Xh);$g=null;if(!$nh&&support("kill")){$g=connect();if($g&&($j==""||$g->select_db($j))){$je=get_val(connection_id(),0,$g);echo +script("const timeout = setTimeout(() => { ajax('".js_escape(ME)."script=kill', function () {}, 'kill=$je&token=".get_token()."'); }, 1000 * $Xh);");}}ob_flush();flush();$K=@get_key_vals(($nh?:$I),$g,false);if($g){echo +script("clearTimeout(timeout);");ob_flush();flush();}return$K;}function +get_token(){$wg=rand(1,1e6);return($wg^$_SESSION["token"]).":$wg";}function +verify_token(){list($fi,$wg)=explode(":",$_POST["token"]);return($wg^$_SESSION["token"])==$fi;}function +lzw_decompress($Fa){$Tb=256;$Ga=8;$ab=array();$Hg=0;$Ig=0;for($s=0;$s=$Ga){$Ig-=$Ga;$ab[]=$Hg>>$Ig;$Hg&=(1<<$Ig)-1;$Tb++;if($Tb>>$Ga)$Ga++;}}$Sb=range("\0","\xFF");$K="";$aj="";foreach($ab +as$s=>$Za){$gc=$Sb[$Za];if(!isset($gc))$gc=$aj.$aj[0];$K +.=$gc;if($s)$Sb[]=$aj.$gc[0];$aj=$gc;}return$K;}function +script($ph,$ii="\n"){return"$ph$ii";}function +script_src($Ei,$Mb=false){return"\n";}function +nonce(){return' nonce="'.get_nonce().'"';}function +input_hidden($D,$Y=""){return"\n";}function +input_token(){return +input_hidden("token",get_token());}function +target_blank(){return' target="_blank" rel="noreferrer noopener"';}function +h($yh){return +str_replace("\0","�",htmlspecialchars($yh,ENT_QUOTES,'utf-8'));}function +nl_br($yh){return +str_replace("\n","
",$yh);}function +checkbox($D,$Y,$Ta,$le="",$rf="",$Xa="",$ne=""){$K="".($rf?script("qsl('input').onclick = function () { $rf };",""):"");return($le!=""||$Xa?"$K".h($le)."":$K);}function +optionlist($vf,$Xg=null,$Ii=false){$K="";foreach($vf +as$he=>$W){$wf=array($he=>$W);if(is_array($W)){$K +.='';$wf=$W;}foreach($wf +as$y=>$X)$K +.=''.h($X);if(is_array($W))$K +.='';}return$K;}function +html_select($D,array$vf,$Y="",$qf="",$ne=""){static$le=0;$me="";if(!$ne&&substr($vf[""],0,1)=="("){$le++;$ne="label-$le";$me="

","$ue",script("qsl('a').onclick = partial(toggle, 'fieldset-$t');",""),"","
\n";}function +selectSearchPrint(array$Z,array$d,array$w){print_fieldset("search",lang(54),$Z);foreach($w +as$s=>$v){if($v["type"]=="FULLTEXT")echo"
(".implode(", ",array_map('Adminer\h',$v["columns"])).") AGAINST"," ",script("qsl('input').oninput = selectFieldChange;",""),(JUSH=='sql'?checkbox("boolean[$s]",1,isset($_GET["boolean"][$s]),"BOOL"):''),"
\n";}$Pa="this.parentNode.firstChild.onchange();";foreach(array_merge((array)$_GET["where"],array(array()))as$s=>$X){if(!$X||("$X[col]$X[val]"!=""&&in_array($X["op"],adminer()->operators())))echo"
".select_input(" name='where[$s][col]'",$d,$X["col"],($X?"selectFieldChange":"selectAddRow"),"(".lang(55).")"),html_select("where[$s][op]",adminer()->operators(),$X["op"],$Pa),"",script("mixin(qsl('input'), {oninput: function () { $Pa }, onkeydown: selectSearchKeydown, onsearch: selectSearchSearch});",""),"
\n";}echo"\n";}function +selectOrderPrint(array$xf,array$d,array$w){print_fieldset("sort",lang(56),$xf);$s=0;foreach((array)$_GET["order"]as$y=>$X){if($X!=""){echo"
".select_input(" name='order[$s]'",$d,$X,"selectFieldChange"),checkbox("desc[$s]",1,isset($_GET["desc"][$y]),lang(57))."
\n";$s++;}}echo"
".select_input(" name='order[$s]'",$d,"","selectAddRow"),checkbox("desc[$s]",1,false,lang(57))."
\n","\n";}function +selectLimitPrint($z){echo"
".lang(58)."
","",script("qsl('input').oninput = selectFieldChange;",""),"
\n";}function +selectLengthPrint($Vh){if($Vh!==null)echo"
".lang(59)."
","","
\n";}function +selectActionPrint(array$w){echo"
".lang(60)."
",""," ","\n","const indexColumns = ";$d=array();foreach($w +as$v){$Ab=reset($v["columns"]);if($v["type"]!="FULLTEXT"&&$Ab)$d[$Ab]=1;}$d[""]=1;foreach($d +as$y=>$X)json_row($y);echo";\n","selectFieldChange.call(qs('#form')['select']);\n","\n","
\n";}function +selectCommandPrint(){return!information_schema(DB);}function +selectImportPrint(){return!information_schema(DB);}function +selectEmailPrint(array$ic,array$d){}function +selectColumnsProcess(array$d,array$w){$N=array();$r=array();foreach((array)$_GET["columns"]as$y=>$X){if($X["fun"]=="count"||($X["col"]!=""&&(!$X["fun"]||in_array($X["fun"],driver()->functions)||in_array($X["fun"],driver()->grouping)))){$N[$y]=apply_sql_function($X["fun"],($X["col"]!=""?idf_escape($X["col"]):"*"));if(!in_array($X["fun"],driver()->grouping))$r[]=$N[$y];}}return +array($N,$r);}function +selectSearchProcess(array$m,array$w){$K=array();foreach($w +as$s=>$v){if($v["type"]=="FULLTEXT"&&idx($_GET["fulltext"],$s)!="")$K[]="MATCH (".implode(", ",array_map('Adminer\idf_escape',$v["columns"])).") AGAINST (".q($_GET["fulltext"][$s]).(isset($_GET["boolean"][$s])?" IN BOOLEAN MODE":"").")";}foreach((array)$_GET["where"]as$y=>$X){$bb=$X["col"];if("$bb$X[val]"!=""&&in_array($X["op"],adminer()->operators())){$mb=array();foreach(($bb!=""?array($bb=>$m[$bb]):$m)as$D=>$l){$kg="";$lb=" $X[op]";if(preg_match('~IN$~',$X["op"])){$Ed=process_length($X["val"]);$lb +.=" ".($Ed!=""?$Ed:"(NULL)");}elseif($X["op"]=="SQL")$lb=" $X[val]";elseif(preg_match('~^(I?LIKE) %%$~',$X["op"],$B))$lb=" $B[1] ".adminer()->processInput($l,"%$X[val]%");elseif($X["op"]=="FIND_IN_SET"){$kg="$X[op](".q($X["val"]).", ";$lb=")";}elseif(!preg_match('~NULL$~',$X["op"]))$lb +.=" ".adminer()->processInput($l,$X["val"]);if($bb!=""||(isset($l["privileges"]["where"])&&(preg_match('~^[-\d.'.(preg_match('~IN$~',$X["op"])?',':'').']+$~',$X["val"])||!preg_match('~'.number_type().'|bit~',$l["type"]))&&(!preg_match("~[\x80-\xFF]~",$X["val"])||preg_match('~char|text|enum|set~',$l["type"]))&&(!preg_match('~date|timestamp~',$l["type"])||preg_match('~^\d+-\d+-\d+~',$X["val"]))))$mb[]=$kg.driver()->convertSearch(idf_escape($D),$X,$l).$lb;}$K[]=(count($mb)==1?$mb[0]:($mb?"(".implode(" OR ",$mb).")":"1 = 0"));}}return$K;}function +selectOrderProcess(array$m,array$w){$K=array();foreach((array)$_GET["order"]as$y=>$X){if($X!="")$K[]=(preg_match('~^((COUNT\(DISTINCT |[A-Z0-9_]+\()(`(?:[^`]|``)+`|"(?:[^"]|"")+")\)|COUNT\(\*\))$~',$X)?$X:idf_escape($X)).(isset($_GET["desc"][$y])?" DESC":"");}return$K;}function +selectLimitProcess(){return(isset($_GET["limit"])?intval($_GET["limit"]):50);}function +selectLengthProcess(){return(isset($_GET["text_length"])?"$_GET[text_length]":"100");}function +selectEmailProcess(array$Z,array$Wc){return +false;}function +selectQueryBuild(array$N,array$Z,array$r,array$xf,$z,$F){return"";}function +messageQuery($I,$Wh,$Ec=false){restart_session();$td=&get_session("queries");if(!idx($td,$_GET["db"]))$td[$_GET["db"]]=array();if(strlen($I)>1e6)$I=preg_replace('~[\x80-\xFF]+$~','',substr($I,0,1e6))."\n…";$td[$_GET["db"]][]=array($I,time(),$Wh);$uh="sql-".count($td[$_GET["db"]]);$K="".lang(62)." 🗐\n";if(!$Ec&&($Wi=driver()->warnings())){$t="warnings-".count($td[$_GET["db"]]);$K="".lang(44).", $K\n";}return" ".@date("H:i:s").""." $K';}function +editRowPrint($R,array$m,$L,$Ai){}function +editFunctions(array$l){$K=($l["null"]?"NULL/":"");$Ai=isset($_GET["select"])||where($_GET);foreach(array(driver()->insertFunctions,driver()->editFunctions)as$y=>$dd){if(!$y||(!isset($_GET["call"])&&$Ai)){foreach($dd +as$Yf=>$X){if(!$Yf||preg_match("~$Yf~",$l["type"]))$K +.="/$X";}}if($y&&$dd&&!preg_match('~set|bool~',$l["type"])&&!is_blob($l))$K +.="/SQL";}if($l["auto_increment"]&&!$Ai)$K=lang(49);return +explode("/",$K);}function +editInput($R,array$l,$b,$Y){if($l["type"]=="enum")return(isset($_GET["select"])?" ":"").enum_input("radio",$b,$l,$Y,"NULL");return"";}function +editHint($R,array$l,$Y){return"";}function +processInput(array$l,$Y,$q=""){if($q=="SQL")return$Y;$D=$l["field"];$K=q($Y);if(preg_match('~^(now|getdate|uuid)$~',$q))$K="$q()";elseif(preg_match('~^current_(date|timestamp)$~',$q))$K=$q;elseif(preg_match('~^([+-]|\|\|)$~',$q))$K=idf_escape($D)." $q $K";elseif(preg_match('~^[+-] interval$~',$q))$K=idf_escape($D)." $q ".(preg_match("~^(\\d+|'[0-9.: -]') [A-Z_]+\$~i",$Y)&&JUSH!="pgsql"?$Y:$K);elseif(preg_match('~^(addtime|subtime|concat)$~',$q))$K="$q(".idf_escape($D).", $K)";elseif(preg_match('~^(md5|sha1|password|encrypt)$~',$q))$K="$q($K)";return +unconvert_field($l,$K);}function +dumpOutput(){$K=array('text'=>lang(63),'file'=>lang(64));if(function_exists('gzencode'))$K['gz']='gzip';return$K;}function +dumpFormat(){return(support("dump")?array('sql'=>'SQL'):array())+array('csv'=>'CSV,','csv;'=>'CSV;','tsv'=>'TSV');}function +dumpDatabase($j){}function +dumpTable($R,$_h,$ee=0){if($_POST["format"]!="sql"){echo"\xef\xbb\xbf";if($_h)dump_csv(array_keys(fields($R)));}else{if($ee==2){$m=array();foreach(fields($R)as$D=>$l)$m[]=idf_escape($D)." $l[full_type]";$h="CREATE TABLE ".table($R)." (".implode(", ",$m).")";}else$h=create_sql($R,$_POST["auto_increment"],$_h);set_utf8mb4($h);if($_h&&$h){if($_h=="DROP+CREATE"||$ee==1)echo"DROP ".($ee==2?"VIEW":"TABLE")." IF EXISTS ".table($R).";\n";if($ee==1)$h=remove_definer($h);echo"$h;\n\n";}}}function +dumpData($R,$_h,$I){if($_h){$He=(JUSH=="sqlite"?0:1048576);$m=array();$Ad=false;if($_POST["format"]=="sql"){if($_h=="TRUNCATE+INSERT")echo +truncate_sql($R).";\n";$m=fields($R);if(JUSH=="mssql"){foreach($m +as$l){if($l["auto_increment"]){echo"SET IDENTITY_INSERT ".table($R)." ON;\n";$Ad=true;break;}}}}$J=connection()->query($I,1);if($J){$Vd="";$Ka="";$ie=array();$ed=array();$Bh="";$Hc=($R!=''?'fetch_assoc':'fetch_row');$vb=0;while($L=$J->$Hc()){if(!$ie){$Oi=array();foreach($L +as$X){$l=$J->fetch_field();if(idx($m[$l->name],'generated')){$ed[$l->name]=true;continue;}$ie[]=$l->name;$y=idf_escape($l->name);$Oi[]="$y = VALUES($y)";}$Bh=($_h=="INSERT+UPDATE"?"\nON DUPLICATE KEY UPDATE ".implode(", ",$Oi):"").";\n";}if($_POST["format"]!="sql"){if($_h=="table"){dump_csv($ie);$_h="INSERT";}dump_csv($L);}else{if(!$Vd)$Vd="INSERT INTO ".table($R)." (".implode(", ",array_map('Adminer\idf_escape',$ie)).") VALUES";foreach($L +as$y=>$X){if($ed[$y]){unset($L[$y]);continue;}$l=$m[$y];$L[$y]=($X!==null?unconvert_field($l,preg_match(number_type(),$l["type"])&&!preg_match('~\[~',$l["full_type"])&&is_numeric($X)?$X:q(($X===false?0:$X))):"NULL");}$Pg=($He?"\n":" ")."(".implode(",\t",$L).")";if(!$Ka)$Ka=$Vd.$Pg;elseif(JUSH=='mssql'?$vb%1000!=0:strlen($Ka)+4+strlen($Pg)+strlen($Bh)<$He)$Ka +.=",$Pg";else{echo$Ka.$Bh;$Ka=$Vd.$Pg;}}$vb++;}if($Ka)echo$Ka.$Bh;}elseif($_POST["format"]=="sql")echo"-- ".str_replace("\n"," ",connection()->error)."\n";if($Ad)echo"SET IDENTITY_INSERT ".table($R)." OFF;\n";}}function +dumpFilename($_d){return +friendly_url($_d!=""?$_d:(SERVER?:"localhost"));}function +dumpHeaders($_d,$Ze=false){$If=$_POST["output"];$Ac=(preg_match('~sql~',$_POST["format"])?"sql":($Ze?"tar":"csv"));header("Content-Type: ".($If=="gz"?"application/x-gzip":($Ac=="tar"?"application/x-tar":($Ac=="sql"||$If!="file"?"text/plain":"text/csv")."; charset=utf-8")));if($If=="gz"){ob_start(function($yh){return +gzencode($yh);},1e6);}return$Ac;}function +dumpFooter(){if($_POST["format"]=="sql")echo"-- ".gmdate("Y-m-d H:i:s e")."\n";}function +importServerPath(){return"adminer.sql";}function +homepage(){echo'

".adminer()->name()." ".VERSION;$ff=$_COOKIE["adminer_version"];echo" ".(version_compare(VERSION,$ff)<0?h($ff):"")."","

\n";switch_lang();if($We=="auth"){$If="";foreach((array)$_SESSION["pwds"]as$Qi=>$gh){foreach($gh +as$O=>$Ki){$D=h(get_setting("vendor-$Qi-$O")?:get_driver($Qi));foreach($Ki +as$V=>$G){if($G!==null){$Hb=$_SESSION["db"][$Qi][$O][$V];foreach(($Hb?array_keys($Hb):array(""))as$j)$If +.="
  • ($D) ".h("$V@".($O!=""?adminer()->serverName($O):"").($j!=""?" - $j":""))."\n";}}}}if($If)echo"
      \n$If
    \n".script("mixin(qs('#logins'), {onmouseover: menuOver, onmouseout: menuOut});");}else{$T=array();if($_GET["ns"]!==""&&!$We&&DB!=""){connection()->select_db(DB);$T=table_status('',true);}adminer()->syntaxHighlighting($T);adminer()->databasesPrint($We);$ha=array();if(DB==""||!$We){if(support("sql")){$ha[]="".lang(62)."";$ha[]="".lang(73)."";}$ha[]="".lang(74)."";}$Fd=$_GET["ns"]!==""&&!$We&&DB!="";if($Fd&&function_exists('Adminer\alter_table'))$ha[]='".lang(75)."";echo($ha?"

    ".lang(11)."

    \n";}}}function +syntaxHighlighting(array$T){echo +script_src(preg_replace("~\\?.*~","",ME)."?file=jush.js&version=5.4.2",true);if(support("sql")){echo"\n";if($T){$ze=array();foreach($T +as$R=>$U)$ze[]=preg_quote($R,'/');echo"var jushLinks = { ".JUSH.":";json_row(js_escape(ME).(support("table")?"table":"select").'=$&','/\b('.implode('|',$ze).')\b/g',false);if(support('routine')){foreach(routines()as$L)json_row(js_escape(ME).'function='.urlencode($L["SPECIFIC_NAME"]).'&name=$&','/\b'.preg_quote($L["ROUTINE_NAME"],'/').'(?=["`]?\()/g',false);}json_row('');echo"};\n";foreach(array("bac","bra","sqlite_quo","mssql_bra")as$X)echo"jushLinks.$X = jushLinks.".JUSH.";\n";if(isset($_GET["sql"])||isset($_GET["trigger"])||isset($_GET["check"])){$Lh=array_fill_keys(array_keys($T),array());foreach(driver()->allFields()as$R=>$m){foreach($m +as$l)$Lh[$R][]=$l["field"];}echo"addEventListener('DOMContentLoaded', () => { autocompleter = jush.autocompleteSql('".idf_escape("")."', ".json_encode($Lh)."); });\n";}}echo"\n";}echo +script("syntaxHighlighting('".preg_replace('~^(\d\.?\d).*~s','\1',connection()->server_info)."', '".connection()->flavor."');");}function +databasesPrint($We){$i=adminer()->databases();if(DB&&$i&&!in_array(DB,$i))array_unshift($i,DB);echo"
    \n

    \n";hidden_fields_get();$Fb=script("mixin(qsl('select'), {onmousedown: dbMouseDown, onchange: dbChange});");echo"","\n";if(support("scheme")){if($We!="db"&&DB!=""&&connection()->select_db(DB)){echo"
    ";if($_GET["ns"]!="")set_schema($_GET["ns"]);}}foreach(array("import","sql","schema","dump","privileges")as$X){if(isset($_GET[$X])){echo +input_hidden($X);break;}}echo"

    \n";}function +tablesPrint(array$T){echo"
      ".script("mixin(qs('#tables'), {onmouseover: menuOver, onmouseout: menuOut});");foreach($T +as$R=>$Q){$R="$R";$D=adminer()->tableName($Q);if($D!=""&&!$Q["partition"])echo'
    • ".lang(78)." ",(support("table")||support("indexes")?'$D":"$D")."\n";}echo"
    \n";}function +showVariables(){return +show_variables();}function +showStatus(){return +show_status();}function +processList(){return +process_list();}function +killProcess($t){return +kill_process($t);}}class +Plugins{private +static$append=array('dumpFormat'=>true,'dumpOutput'=>true,'editRowPrint'=>true,'editFunctions'=>true,'config'=>true);var$plugins;var$error='';private$hooks=array();function +__construct($dg){if($dg===null){$dg=array();$Ea="adminer-plugins";if(is_dir($Ea)){foreach(glob("$Ea/*.php")as$n)$this->includeOnce($n);}$sd=" href='https://www.adminer.org/plugins/#use'".target_blank();if(file_exists("$Ea.php")){$Gd=$this->includeOnce("$Ea.php");if(is_array($Gd)){foreach($Gd +as$cg)$dg[get_class($cg)]=$cg;}else$this->error +.=lang(79,"$Ea.php",$sd)."
    ";}foreach(get_declared_classes()as$Xa){if(!$dg[$Xa]&&(preg_match('~^Adminer\w~i',$Xa)||is_subclass_of($Xa,'Adminer\Plugin'))){$Cg=new +\ReflectionClass($Xa);$qb=$Cg->getConstructor();if($qb&&$qb->getNumberOfRequiredParameters())$this->error +.=lang(80,$sd,"$Xa","$Ea.php")."
    ";else$dg[$Xa]=new$Xa;}}}$this->plugins=$dg;$ja=new +Adminer;$dg[]=$ja;$Cg=new +\ReflectionObject($ja);foreach($Cg->getMethods()as$Ue){foreach($dg +as$cg){$D=$Ue->getName();if(method_exists($cg,$D))$this->hooks[$D][]=$cg;}}}function +includeOnce($n){return +include_once"./$n";}function +__call($D,array$Mf){$ta=array();foreach($Mf +as$y=>$X)$ta[]=&$Mf[$y];$K=null;foreach($this->hooks[$D]as$cg){$Y=call_user_func_array(array($cg,$D),$ta);if($Y!==null){if(!self::$append[$D])return$Y;$K=$Y+(array)$K;}}return$K;}}abstract +class +Plugin{protected$translations=array();function +description(){return$this->lang('');}function +screenshot(){return"";}protected +function +lang($u,$E=null){$ta=func_get_args();$ta[0]=idx($this->translations[LANG],$u)?:$u;return +call_user_func_array('Adminer\lang_format',$ta);}}Adminer::$instance=(function_exists('adminer_object')?adminer_object():(is_dir("adminer-plugins")||file_exists("adminer-plugins.php")?new +Plugins(null):new +Adminer));define('Adminer\JUSH',Driver::$jush);define('Adminer\SERVER',"".$_GET[DRIVER]);define('Adminer\DB',"$_GET[db]");define('Adminer\ME',preg_replace('~\?.*~','',relative_uri()).'?'.(sid()?SID.'&':'').(SERVER!==null?DRIVER."=".urlencode(SERVER).'&':'').($_GET["ext"]?"ext=".urlencode($_GET["ext"]).'&':'').(isset($_GET["username"])?"username=".urlencode($_GET["username"]).'&':'').(DB!=""?'db='.urlencode(DB).'&'.(isset($_GET["ns"])?"ns=".urlencode($_GET["ns"])."&":""):''));function +page_header($Zh,$k="",$Ja=array(),$ai=""){page_headers();if(is_ajax()&&$k){page_messages($k);exit;}if(!ob_get_level())ob_start('ob_gzhandler',4096);$bi=$Zh.($ai!=""?": $ai":"");$ci=strip_tags($bi.(SERVER!=""&&SERVER!="localhost"?h(" - ".SERVER):"")." - ".adminer()->name());echo' + + + + +',$ci,' + +';$zb=adminer()->css();if(is_int(key($zb)))$zb=array_fill_keys($zb,'light');$od=in_array('light',$zb)||in_array('',$zb);$md=in_array('dark',$zb)||in_array('',$zb);$Bb=($od?($md?null:false):($md?:null));$Ne=" media='(prefers-color-scheme: dark)'";if($Bb!==false)echo"\n";echo"\n",script_src(preg_replace("~\\?.*~","",ME)."?file=functions.js&version=5.4.2");if(adminer()->head($Bb))echo"\n","\n";foreach($zb +as$Ei=>$Xe){$b=($Xe=='dark'&&!$Bb?$Ne:($Xe=='light'&&$md?" media='(prefers-color-scheme: light)'":""));echo"\n";}echo"\n\n";$n=get_temp_dir()."/adminer.version";echo +script("mixin(document.body, {onkeydown: bodyKeydown, onclick: bodyClick".(isset($_COOKIE["adminer_version"])?"":", onload: partial(verifyVersion, '".VERSION."')")."}); +document.body.classList.replace('nojs', 'js'); +const offlineMessage = '".js_escape(lang(82))."'; +const thousandsSeparator = '".js_escape(lang(4))."';"),"\n",script("mixin(qs('#help'), {onmouseover: () => { helpOpen = 1; }, onmouseout: helpMouseout});"),"
    \n","".icon("move","","menu","")."".script("qs('#menuopen').onclick = event => { qs('#foot').classList.toggle('foot'); event.stopPropagation(); }");if($Ja!==null){$_=substr(preg_replace('~\b(username|db|ns)=[^&]*&~','',ME),0,-1);echo'

    $bi

    \n","\n";restart_session();page_messages($k);$i=&get_session("dbs");if(DB!=""&&$i&&!in_array(DB,$i,true))$i=null;stop_session();define('Adminer\PAGE_HEADER',1);}function +page_headers(){header("Content-Type: text/html; charset=utf-8");header("Cache-Control: no-cache");header("X-Frame-Options: deny");header("X-XSS-Protection: 0");header("X-Content-Type-Options: nosniff");header("Referrer-Policy: origin-when-cross-origin");foreach(adminer()->csp(csp())as$yb){$qd=array();foreach($yb +as$y=>$X)$qd[]="$y $X";header("Content-Security-Policy: ".implode("; ",$qd));}adminer()->headers();}function +csp(){return +array(array("script-src"=>"'self' 'unsafe-inline' 'nonce-".get_nonce()."' 'strict-dynamic'","connect-src"=>"'self' https://www.adminer.org","frame-src"=>"'none'","object-src"=>"'none'","base-uri"=>"'none'","form-action"=>"'self'",),);}function +get_nonce(){static$hf;if(!$hf)$hf=base64_encode(rand_string());return$hf;}function +page_messages($k){$Di=preg_replace('~^[^?]*~','',$_SERVER["REQUEST_URI"]);$Te=idx($_SESSION["messages"],$Di);if($Te){echo"
    ".implode("
    \n
    ",$Te)."
    ".script("messagesPrint();");unset($_SESSION["messages"][$Di]);}if($k)echo"
    $k
    \n";if(adminer()->error)echo"
    ".adminer()->error."
    \n";}function +page_footer($We=""){echo"
    \n\n\n\n",script("setupSubmitHighlight(document);");}function +int32($bf){while($bf>=2147483648)$bf-=4294967296;while($bf<=-2147483649)$bf+=4294967296;return(int)$bf;}function +long2str(array$W,$Vi){$Pg='';foreach($W +as$X)$Pg +.=pack('V',$X);if($Vi)return +substr($Pg,0,end($W));return$Pg;}function +str2long($Pg,$Vi){$W=array_values(unpack('V*',str_pad($Pg,4*ceil(strlen($Pg)/4),"\0")));if($Vi)$W[]=strlen($Pg);return$W;}function +xxtea_mx($cj,$bj,$Ch,$he){return +int32((($cj>>5&0x7FFFFFF)^$bj<<2)+(($bj>>3&0x1FFFFFFF)^$cj<<4))^int32(($Ch^$bj)+($he^$cj));}function +encrypt_string($xh,$y){if($xh=="")return"";$y=array_values(unpack("V*",pack("H*",md5($y))));$W=str2long($xh,true);$bf=count($W)-1;$cj=$W[$bf];$bj=$W[0];$H=floor(6+52/($bf+1));$Ch=0;while($H-->0){$Ch=int32($Ch+0x9E3779B9);$cc=$Ch>>2&3;for($Jf=0;$Jf<$bf;$Jf++){$bj=$W[$Jf+1];$af=xxtea_mx($cj,$bj,$Ch,$y[$Jf&3^$cc]);$cj=int32($W[$Jf]+$af);$W[$Jf]=$cj;}$bj=$W[0];$af=xxtea_mx($cj,$bj,$Ch,$y[$Jf&3^$cc]);$cj=int32($W[$bf]+$af);$W[$bf]=$cj;}return +long2str($W,false);}function +decrypt_string($xh,$y){if($xh=="")return"";if(!$y)return +false;$y=array_values(unpack("V*",pack("H*",md5($y))));$W=str2long($xh,false);$bf=count($W)-1;$cj=$W[$bf];$bj=$W[0];$H=floor(6+52/($bf+1));$Ch=int32($H*0x9E3779B9);while($Ch){$cc=$Ch>>2&3;for($Jf=$bf;$Jf>0;$Jf--){$cj=$W[$Jf-1];$af=xxtea_mx($cj,$bj,$Ch,$y[$Jf&3^$cc]);$bj=int32($W[$Jf]-$af);$W[$Jf]=$bj;}$cj=$W[$bf];$af=xxtea_mx($cj,$bj,$Ch,$y[$Jf&3^$cc]);$bj=int32($W[0]-$af);$W[0]=$bj;$Ch=int32($Ch-0x9E3779B9);}return +long2str($W,true);}$ag=array();if($_COOKIE["adminer_permanent"]){foreach(explode(" ",$_COOKIE["adminer_permanent"])as$X){list($y)=explode(":",$X);$ag[$y]=$X;}}function +add_invalid_login(){$Ca=get_temp_dir()."/adminer.invalid";foreach(glob("$Ca*")?:array($Ca)as$n){$p=file_open_lock($n);if($p)break;}if(!$p)$p=file_open_lock("$Ca-".rand_string());if(!$p)return;$Zd=unserialize(stream_get_contents($p));$Wh=time();if($Zd){foreach($Zd +as$ae=>$X){if($X[0]<$Wh)unset($Zd[$ae]);}}$Yd=&$Zd[adminer()->bruteForceKey()];if(!$Yd)$Yd=array($Wh+30*60,0);$Yd[1]++;file_write_unlock($p,serialize($Zd));}function +check_invalid_login(array&$ag){$Zd=array();foreach(glob(get_temp_dir()."/adminer.invalid*")as$n){$p=file_open_lock($n);if($p){$Zd=unserialize(stream_get_contents($p));file_unlock($p);break;}}$Yd=idx($Zd,adminer()->bruteForceKey(),array());$gf=($Yd[1]>29?$Yd[0]-time():0);if($gf>0)auth_error(lang(84,ceil($gf/60)),$ag);}$xa=$_POST["auth"];if($xa){session_regenerate_id();$Qi=$xa["driver"];$O=$xa["server"];$V=$xa["username"];$G=(string)$xa["password"];$j=$xa["db"];set_password($Qi,$O,$V,$G);$_SESSION["db"][$Qi][$O][$V][$j]=true;if($xa["permanent"]){$y=implode("-",array_map('base64_encode',array($Qi,$O,$V,$j)));$pg=adminer()->permanentLogin(true);$ag[$y]="$y:".base64_encode($pg?encrypt_string($G,$pg):"");cookie("adminer_permanent",implode(" ",$ag));}if(count($_POST)==1||DRIVER!=$Qi||SERVER!=$O||$_GET["username"]!==$V||DB!=$j)redirect(auth_url($Qi,$O,$V,$j));}elseif($_POST["logout"]&&(!$_SESSION["token"]||verify_token())){foreach(array("pwds","db","dbs","queries")as$y)set_session($y,null);unset_permanent($ag);redirect(substr(preg_replace('~\b(username|db|ns)=[^&]*&~','',ME),0,-1),lang(85).' '.lang(86));}elseif($ag&&!$_SESSION["pwds"]){session_regenerate_id();$pg=adminer()->permanentLogin();foreach($ag +as$y=>$X){list(,$Wa)=explode(":",$X);list($Qi,$O,$V,$j)=array_map('base64_decode',explode("-",$y));set_password($Qi,$O,$V,decrypt_string(base64_decode($Wa),$pg));$_SESSION["db"][$Qi][$O][$V][$j]=true;}}function +unset_permanent(array&$ag){foreach($ag +as$y=>$X){list($Qi,$O,$V,$j)=array_map('base64_decode',explode("-",$y));if($Qi==DRIVER&&$O==SERVER&&$V==$_GET["username"]&&$j==DB)unset($ag[$y]);}cookie("adminer_permanent",implode(" ",$ag));}function +auth_error($k,array&$ag){$hh=session_name();if(isset($_GET["username"])){header("HTTP/1.1 403 Forbidden");if(($_COOKIE[$hh]||$_GET[$hh])&&!$_SESSION["token"])$k=lang(87);else{restart_session();add_invalid_login();$G=get_password();if($G!==null){if($G===false)$k +.=($k?'
    ':'').lang(88,target_blank(),'permanentLogin()');set_password(DRIVER,SERVER,$_GET["username"],null);}unset_permanent($ag);}}if(!$_COOKIE[$hh]&&$_GET[$hh]&&ini_bool("session.use_only_cookies"))$k=lang(89);$Mf=session_get_cookie_params();cookie("adminer_key",($_COOKIE["adminer_key"]?:rand_string()),$Mf["lifetime"]);if(!$_SESSION["token"])$_SESSION["token"]=rand(1,1e6);page_header(lang(36),$k,null);echo"
    \n","
    ";if(hidden_fields($_POST,array("auth")))echo"

    ".lang(90)."\n";echo"

    \n";adminer()->loginForm();echo"
    \n";page_footer("auth");exit;}if(isset($_GET["username"])&&!class_exists('Adminer\Db')){unset($_SESSION["pwds"][DRIVER]);unset_permanent($ag);page_header(lang(91),lang(92,implode(", ",Driver::$extensions)),false);page_footer("auth");exit;}$f='';if(isset($_GET["username"])&&is_string(get_password())){list(,$eg)=host_port(SERVER);if(preg_match('~^\s*([-+]?\d+)~',$eg,$B)&&($B[1]<1024||$B[1]>65535))auth_error(lang(93),$ag);check_invalid_login($ag);$xb=adminer()->credentials();$f=Driver::connect($xb[0],$xb[1],$xb[2]);if(is_object($f)){Db::$instance=$f;Driver::$instance=new +Driver($f);if($f->flavor)save_settings(array("vendor-".DRIVER."-".SERVER=>get_driver(DRIVER)));}}$_e=null;if(!is_object($f)||($_e=adminer()->login($_GET["username"],get_password()))!==true){$k=(is_string($f)?nl_br(h($f)):(is_string($_e)?$_e:lang(94))).(preg_match('~^ | $~',get_password())?'
    '.lang(95):'');auth_error($k,$ag);}if($_POST["logout"]&&$_SESSION["token"]&&!verify_token()){page_header(lang(83),lang(96));page_footer("db");exit;}if(!$_SESSION["token"])$_SESSION["token"]=rand(1,1e6);stop_session(true);if($xa&&$_POST["token"])$_POST["token"]=get_token();$k='';if($_POST){if(!verify_token()){$Sd="max_input_vars";$Le=ini_get($Sd);if(extension_loaded("suhosin")){foreach(array("suhosin.request.max_vars","suhosin.post.max_vars")as$y){$X=ini_get($y);if($X&&(!$Le||$X<$Le)){$Sd=$y;$Le=$X;}}}$k=(!$_POST["token"]&&$Le?lang(97,"'$Sd'"):lang(96).' '.lang(98));}}elseif($_SERVER["REQUEST_METHOD"]=="POST"){$k=lang(99,"'post_max_size'");if(isset($_GET["sql"]))$k +.=' '.lang(100);}function +print_select_result($J,$g=null,array$Af=array(),$z=0){$ze=array();$w=array();$d=array();$Ha=array();$ti=array();$K=array();for($s=0;(!$z||$s<$z)&&($L=$J->fetch_row());$s++){if(!$s){echo"
    \n","\n","";for($x=0;$xfetch_field();$D=$l->name;$_f=(isset($l->orgtable)?$l->orgtable:"");$zf=(isset($l->orgname)?$l->orgname:$D);if($Af&&JUSH=="sql")$ze[$x]=($D=="table"?"table=":($D=="possible_keys"?"indexes=":null));elseif($_f!=""){if(isset($l->table))$K[$l->table]=$_f;if(!isset($w[$_f])){$w[$_f]=array();foreach(indexes($_f,$g)as$v){if($v["type"]=="PRIMARY"){$w[$_f]=array_flip($v["columns"]);break;}}$d[$_f]=$w[$_f];}if(isset($d[$_f][$zf])){unset($d[$_f][$zf]);$w[$_f][$zf]=$x;$ze[$x]=$_f;}}if($l->charsetnr==63)$Ha[$x]=true;$ti[$x]=$l->type;echo"name!=$zf?" title='".h(($_f!=""?"$_f.":"").$zf)."'":"").">".h($D).($Af?'':"");}echo"\n";}echo"";foreach($L +as$y=>$X){$_="";if(isset($ze[$y])&&!$d[$ze[$y]]){if($Af&&JUSH=="sql"){$R=$L[array_search("table=",$ze)];$_=ME.$ze[$y].urlencode($Af[$R]!=""?$Af[$R]:$R);}else{$_=ME."edit=".urlencode($ze[$y]);foreach($w[$ze[$y]]as$bb=>$x){if($L[$x]===null){$_="";break;}$_ +.="&where".urlencode("[".bracket_escape($bb)."]")."=".urlencode($L[$x]);}}}$l=array('type'=>($Ha[$y]?'blob':($ti[$y]==254?'char':'')),);$X=select_value($X,$_,$l,null);echo"$X";}}echo($s?"
    \n
    ":"

    ".lang(14))."\n";return$K;}function +referencable_primary($Zg){$K=array();foreach(table_status('',true)as$Gh=>$R){if($Gh!=$Zg&&fk_support($R)){foreach(fields($Gh)as$l){if($l["primary"]){if($K[$Gh]){unset($K[$Gh]);break;}$K[$Gh]=$l;}}}}return$K;}function +textarea($D,$Y,$M=10,$fb=80){echo"";}function +select_input($b,array$vf,$Y="",$qf="",$bg=""){$Oh=($vf?"select":"input");return"<$Oh$b".($vf?">

    ".lang(120,get_driver(DRIVER),"".h(connection()->server_info)."","".connection()->extension."")."\n","

    ".lang(121,"".h(logged_user())."")."\n";$i=adminer()->databases();if($i){$Sg=support("scheme");$eb=collations();echo"

    \n","\n",script("mixin(qsl('table'), {onclick: tableClick, ondblclick: partialArg(tableClick, true)});"),"".(support("database")?"\n";$i=($_GET["dbsize"]?count_tables($i):array_flip($i));foreach($i +as$j=>$T){$Lg=h(ME)."db=".urlencode($j);$t=h("Db-".$j);echo"".(support("database")?"
    ":"")."".lang(35).(get_session("dbs")!==null?" - ".lang(122)."":"")."".lang(123)."".lang(124)."".lang(125)." - ".lang(126)."".script("qsl('a').onclick = partial(ajaxSetHtml, '".js_escape(ME)."script=connect');","")."
    ".checkbox("db[]",$j,in_array($j,(array)$_POST["db"]),"","","",$t):""),"".h($j)."";$db=h(db_collation($j,$eb));echo"".(support("database")?"$db":$db),"".($_GET["dbsize"]?$T:"?")."","".($_GET["dbsize"]?db_size($j):"?"),"\n";}echo"
    \n",(support("database")?"\n":""),input_token(),"
    \n",script("tableCheck();");}if(!empty(adminer()->plugins)){echo"
    \n","

    ".lang(129)."

    \n
      \n";foreach(adminer()->plugins +as$cg){$Qb=(method_exists($cg,'description')?$cg->description():"");if(!$Qb){$Cg=new +\ReflectionObject($cg);if(preg_match('~^/[\s*]+(.+)~',$Cg->getDocComment(),$B))$Qb=$B[1];}$Tg=(method_exists($cg,'screenshot')?$cg->screenshot():"");echo"
    • ".get_class($cg)."".h($Qb?": $Qb":"").($Tg?" (".lang(130).")":"")."\n";}echo"
    \n";adminer()->pluginsLinks();echo"
    \n";}}page_footer("db");exit;}if(support("scheme")){if(DB!=""&&$_GET["ns"]!==""){if(!isset($_GET["ns"]))redirect(preg_replace('~ns=[^&]*&~','',ME)."ns=".get_schema());if(!set_schema($_GET["ns"])){header("HTTP/1.1 404 Not Found");page_header(lang(77).": ".h($_GET["ns"]),lang(131),true);page_footer("ns");exit;}}}adminer()->afterConnect();class +TmpFile{private$handler;var$size;function +__construct(){$this->handler=tmpfile();}function +write($sb){$this->size+=strlen($sb);fwrite($this->handler,$sb);}function +send(){fseek($this->handler,0);fpassthru($this->handler);fclose($this->handler);}}if(isset($_GET["select"])&&($_POST["edit"]||$_POST["clone"])&&!$_POST["save"])$_GET["edit"]=$_GET["select"];if(isset($_GET["callf"]))$_GET["call"]=$_GET["callf"];if(isset($_GET["function"]))$_GET["procedure"]=$_GET["function"];if(isset($_GET["download"])){$a=$_GET["download"];$m=fields($a);header("Content-Type: application/octet-stream");header("Content-Disposition: attachment; filename=".friendly_url("$a-".implode("_",$_GET["where"])).".".friendly_url($_GET["field"]));$N=array(idf_escape($_GET["field"]));$J=driver()->select($a,$N,array(where($_GET,$m)),$N);$L=($J?$J->fetch_row():array());echo +driver()->value($L[0],$m[$_GET["field"]]);exit;}elseif(isset($_GET["table"])){$a=$_GET["table"];$m=fields($a);if(!$m)$k=error()?:lang(11);$S=table_status1($a);$D=adminer()->tableName($S);page_header(($m&&is_view($S)?$S['Engine']=='materialized view'?lang(132):lang(133):lang(134)).": ".($D!=""?$D:h($a)),$k);$Kg=array();foreach($m +as$y=>$l)$Kg+=$l["privileges"];adminer()->selectLinks($S,(isset($Kg["insert"])||!support("table")?"":null));$ib=$S["Comment"];if($ib!="")echo"

    ".lang(48).": ".h($ib)."\n";if($m)adminer()->tableStructurePrint($m,$S);function +tables_links(array$T){echo"

    \n";}$Rd=driver()->inheritsFrom($a);if($Rd){echo"

    ".lang(135)."

    \n";tables_links($Rd);}if(support("indexes")&&driver()->supportsIndex($S)){echo"

    ".lang(136)."

    \n";$w=indexes($a);if($w)adminer()->tableIndexesPrint($w,$S);echo'

    ".lang(101)."

    \n";$Xc=foreign_keys($a);if($Xc){echo"\n","\n";foreach($Xc +as$D=>$o){echo"","
    ".lang(138)."".lang(139)."".lang(104)."".lang(103)."
    ".implode(", ",array_map('Adminer\h',$o["source"]))."";$_=($o["db"]!=""?preg_replace('~db=[^&]*~',"db=".urlencode($o["db"]),ME):($o["ns"]!=""?preg_replace('~ns=[^&]*~',"ns=".urlencode($o["ns"]),ME):ME));echo"".($o["db"]!=""&&$o["db"]!=DB?"".h($o["db"]).".":"").($o["ns"]!=""&&$o["ns"]!=$_GET["ns"]?"".h($o["ns"]).".":"").h($o["table"])."","(".implode(", ",array_map('Adminer\h',$o["target"])).")","".h($o["on_delete"]),"".h($o["on_update"]),''.lang(140).'',"\n";}echo"
    \n";}echo'

    ".lang(142)."

    \n";$Ra=driver()->checkConstraints($a);if($Ra){echo"\n";foreach($Ra +as$y=>$X)echo"","
    ".h($X),"".lang(140)."","\n";echo"
    \n";}echo'

    ".lang(144)."

    \n";$qi=triggers($a);if($qi){echo"\n";foreach($qi +as$y=>$X)echo"
    ".h($X[0])."".h($X[1])."".h($y)."".lang(140)."\n";echo"
    \n";}echo'

    ".lang(146)."

    \n";$Qf=driver()->partitionsInfo($a);if($Qf)echo"

    BY ".h("$Qf[partition_by]($Qf[partition])")."\n";tables_links($Qd);}}elseif(isset($_GET["schema"])){page_header(lang(68),"",array(),h(DB.($_GET["ns"]?".$_GET[ns]":"")));$Ih=array();$Jh=array();$ca=($_GET["schema"]?:$_COOKIE["adminer_schema-".str_replace(".","_",DB)]);preg_match_all('~([^:]+):([-0-9.]+)x([-0-9.]+)(_|$)~',$ca,$De,PREG_SET_ORDER);foreach($De +as$s=>$B){$Ih[$B[1]]=array($B[2],$B[3]);$Jh[]="\n\t'".js_escape($B[1])."': [ $B[2], $B[3] ]";}$gi=0;$Da=-1;$Qg=array();$Bg=array();$te=array();$qa=driver()->allFields();foreach(table_status('',true)as$R=>$S){if(is_view($S))continue;$fg=0;$Qg[$R]["fields"]=array();foreach($qa[$R]as$l){$fg+=1.25;$l["pos"]=$fg;$Qg[$R]["fields"][$l["field"]]=$l;}$Qg[$R]["pos"]=($Ih[$R]?:array($gi,0));foreach(adminer()->foreignKeys($R)as$X){if(!$X["db"]){$re=$Da;if(idx($Ih[$R],1)||idx($Ih[$X["table"]],1))$re=min(idx($Ih[$R],1,0),idx($Ih[$X["table"]],1,0))-1;else$Da-=.1;while($te[(string)$re])$re-=.0001;$Qg[$R]["references"][$X["table"]][(string)$re]=array($X["source"],$X["target"]);$Bg[$X["table"]][$R][(string)$re]=$X["target"];$te[(string)$re]=true;}}$gi=max($gi,$Qg[$R]["pos"][0]+2.5+$fg);}echo'

    + +qs(\'#schema\').onselectstart = () => false; +const tablePos = {',implode(",",$Jh)."\n",'}; +const em = qs(\'#schema\').offsetHeight / ',$gi,'; +document.onmousemove = schemaMousemove; +document.onmouseup = partialArg(schemaMouseup, \'',js_escape(DB),'\'); + +';foreach($Qg +as$D=>$R){echo"
    ",''.h($D)."",script("qsl('div').onmousedown = schemaMousedown;");foreach($R["fields"]as$l){$X=''.h($l["field"]).'';echo"
    ".($l["primary"]?"$X":$X);}foreach((array)$R["references"]as$Qh=>$Dg){foreach($Dg +as$re=>$zg){$se=$re-idx($Ih[$D],1);$s=0;foreach($zg[0]as$ph)echo"\n
    "."
    ";}}foreach((array)$Bg[$D]as$Qh=>$Dg){foreach($Dg +as$re=>$d){$se=$re-idx($Ih[$D],1);$s=0;foreach($d +as$Ph)echo"\n
    "."
    "."
    ";}}echo"\n
    \n";}foreach($Qg +as$D=>$R){foreach((array)$R["references"]as$Qh=>$Dg){foreach($Dg +as$re=>$zg){$Ve=$gi;$Je=-10;foreach($zg[0]as$y=>$ph){$gg=$R["pos"][0]+$R["fields"][$ph]["pos"];$hg=$Qg[$Qh]["pos"][0]+$Qg[$Qh]["fields"][$zg[1][$y]]["pos"];$Ve=min($Ve,$gg,$hg);$Je=max($Je,$gg,$hg);}echo"
    \n";}}}echo'
    +
    + +';$Gb=array('','USE','DROP+CREATE','CREATE');$Kh=array('','DROP+CREATE','CREATE');$Db=array('','TRUNCATE+INSERT','INSERT');if(JUSH=="sql")$Db[]='INSERT+UPDATE';$L=get_settings("adminer_export");if(!$L)$L=array("output"=>"text","format"=>"sql","db_style"=>(DB!=""?"":"CREATE"),"table_style"=>"DROP+CREATE","data_style"=>"INSERT");if(!isset($L["events"])){$L["routines"]=$L["events"]=($_GET["dump"]=="");$L["triggers"]=$L["table_style"];}echo"
    ".lang(148)."".html_radios("output",adminer()->dumpOutput(),$L["output"])."\n","
    ".lang(149)."".html_radios("format",adminer()->dumpFormat(),$L["format"])."\n",(JUSH=="sqlite"?"":"
    ".lang(35)."".html_select('db_style',$Gb,$L["db_style"]).(support("type")?checkbox("types",1,$L["types"],lang(6)):"").(support("routine")?checkbox("routines",1,$L["routines"],lang(70)):"").(support("event")?checkbox("events",1,$L["events"],lang(72)):"")),"
    ".lang(124)."".html_select('table_style',$Kh,$L["table_style"]).checkbox("auto_increment",1,$L["auto_increment"],lang(49)).(support("trigger")?checkbox("triggers",1,$L["triggers"],lang(144)):""),"
    ".lang(150)."".html_select('data_style',$Db,$L["data_style"]),'
    +

    +',input_token(),' + +',script("qsl('table').onclick = dumpClick;");$lg=array();if(DB!=""){$Ta=($a!=""?"":" checked");echo"","\n";$Ti="";$Mh=tables_list();foreach($Mh +as$D=>$U){$kg=preg_replace('~_.*~','',$D);$Ta=($a==""||$a==(substr($a,-1)=="%"?"$kg%":$D));$og="\n";$i=adminer()->databases();if($i){foreach($i +as$j){if(!information_schema($j)){$kg=preg_replace('~_.*~','',$j);echo"
    ".script("qs('#check-tables').onclick = partial(formCheck, /^tables\\[/);",""),"".script("qs('#check-data').onclick = partial(formCheck, /^data\\[/);",""),"
    ".checkbox("tables[]",$D,$Ta,$D,"","block");if($U!==null&&!preg_match('~table~i',$U))$Ti +.="$og\n";else +echo"$og\n";$lg[$kg]++;}echo$Ti;if($Mh)echo +script("ajaxSetHtml('".js_escape(ME)."script=db');");}else{echo"
    ","",script("qs('#check-databases').onclick = partial(formCheck, /^databases\\[/);",""),"
    ".checkbox("databases[]",$j,$a==""||$a=="$kg%",$j,"","block")."\n";$lg[$kg]++;}}}else +echo"
    ";}echo'
    +

    +';$Nc=true;foreach($lg +as$y=>$X){if($y!=""&&$X>1){echo($Nc?"

    ":" ")."".h($y)."";$Nc=false;}}}elseif(isset($_GET["sql"])){if(!$k&&$_POST["export"]){save_settings(array("output"=>$_POST["output"],"format"=>$_POST["format"]),"adminer_import");dump_headers("sql");if($_POST["format"]=="sql")echo"$_POST[query]\n";else{adminer()->dumpTable("","");adminer()->dumpData("","table",$_POST["query"]);adminer()->dumpFooter();}exit;}restart_session();$ud=&get_session("queries");$td=&$ud[DB];if(!$k&&$_POST["clear"]){$td=array();redirect(remove_from_uri("history"));}stop_session();page_header((isset($_GET["import"])?lang(73):lang(62)),$k);$ye='--'.(JUSH=='sql'?' ':'');if(!$k&&$_POST){$p=false;if(!isset($_GET["import"]))$I=$_POST["query"];elseif($_POST["webfile"]){$th=adminer()->importServerPath();$p=@fopen((file_exists($th)?$th:"compress.zlib://$th.gz"),"rb");$I=($p?fread($p,1e6):false);}else$I=get_file("sql_file",true,";");if(is_string($I)){if(function_exists('memory_get_usage')&&($Oe=ini_bytes("memory_limit"))!="-1")@ini_set("memory_limit",max($Oe,strval(2*strlen($I)+memory_get_usage()+8e6)));if($I!=""&&strlen($I)<1e6){$H=$I.(preg_match("~;[ \t\r\n]*\$~",$I)?"":";");if(!$td||first(end($td))!=$H){restart_session();$td[]=array($H,time());set_session("queries",$ud);stop_session();}}$qh="(?:\\s|/\\*[\s\S]*?\\*/|(?:#|$ye)[^\n]*\n?|--\r?\n)";$Ob=driver()->delimiter;$lf=0;$kc=true;$g=connect();if($g&&DB!=""){$g->select_db(DB);if($_GET["ns"]!="")set_schema($_GET["ns"],$g);}$hb=0;$sc=array();$Nf='[\'"'.(JUSH=="sql"?'`#':(JUSH=="sqlite"?'`[':(JUSH=="mssql"?'[':''))).']|/\*|'.$ye.'|$'.(JUSH=="pgsql"?'|\$([a-zA-Z]\w*)?\$':'');$hi=microtime(true);$ka=get_settings("adminer_import");while($I!=""){if(!$lf&&preg_match("~^$qh*+DELIMITER\\s+(\\S+)~i",$I,$B)){$Ob=preg_quote($B[1]);$I=substr($I,strlen($B[0]));}elseif(!$lf&&JUSH=='pgsql'&&preg_match("~^($qh*+COPY\\s+)[^;]+\\s+FROM\\s+stdin;~i",$I,$B)){$Ob="\n\\\\\\.\r?\n";$lf=strlen($B[0]);}else{preg_match("($Ob\\s*|$Nf)",$I,$B,PREG_OFFSET_CAPTURE,$lf);list($Zc,$fg)=$B[0];if(!$Zc&&$p&&!feof($p))$I +.=fread($p,1e5);else{if(!$Zc&&rtrim($I)=="")break;$lf=$fg+strlen($Zc);if($Zc&&!preg_match("(^$Ob)",$Zc)){$Na=driver()->hasCStyleEscapes()||(JUSH=="pgsql"&&($fg>0&&strtolower($I[$fg-1])=="e"));$Yf=($Zc=='/*'?'\*/':($Zc=='['?']':(preg_match("~^$ye|^#~",$Zc)?"\n":preg_quote($Zc).($Na?'|\\\\.':''))));while(preg_match("($Yf|\$)s",$I,$B,PREG_OFFSET_CAPTURE,$lf)){$Pg=$B[0][0];if(!$Pg&&$p&&!feof($p))$I +.=fread($p,1e5);else{$lf=$B[0][1]+strlen($Pg);if(!$Pg||$Pg[0]!="\\")break;}}}else{$kc=false;$H=substr($I,0,$fg+($Ob[0]=="\n"?3:0));$hb++;$og="

    ".adminer()->sqlCommandQuery($H)."
    \n";if(JUSH=="sqlite"&&preg_match("~^$qh*+ATTACH\\b~i",$H,$B)){echo$og,"

    ".lang(151)."\n";$sc[]=" $hb";if($_POST["error_stops"])break;}else{if(!$_POST["only_errors"]){echo$og;ob_flush();flush();}$wh=microtime(true);if(connection()->multi_query($H)&&$g&&preg_match("~^$qh*+USE\\b~i",$H))$g->query($H);do{$J=connection()->store_result();if(connection()->error){echo($_POST["only_errors"]?$og:""),"

    ".lang(152).(connection()->errno?" (".connection()->errno.")":"").": ".error()."\n";$sc[]=" $hb";if($_POST["error_stops"])break +2;}else{$Wh=" (".format_time($wh).")".(strlen($H)<1000?" ".lang(12)."":"");$ma=connection()->affected_rows;$Wi=($_POST["only_errors"]?"":driver()->warnings());$Xi="warnings-$hb";if($Wi)$Wh +.=", ".lang(44)."".script("qsl('a').onclick = partial(toggle, '$Xi');","");$zc=null;$Af=null;$_c="explain-$hb";if(is_object($J)){$z=$_POST["limit"];$Af=print_select_result($J,$g,array(),$z);if(!$_POST["only_errors"]){echo"

    \n";$kf=$J->num_rows;echo"
    \n";}}else{if(preg_match("~^$qh*+(CREATE|DROP|ALTER)$qh++(DATABASE|SCHEMA)\\b~i",$H)){restart_session();set_session("dbs",null);stop_session();}if(!$_POST["only_errors"])echo"

    ".lang(155,$ma)."$Wh\n";}echo($Wi?"

    \n":"");if($zc){echo"\n";}}$wh=microtime(true);}while(connection()->next_result());}$I=substr($I,$lf);$lf=0;}}}}if($kc)echo"

    ".lang(156)."\n";elseif($_POST["only_errors"])echo"

    ".lang(157,$hb-count($sc))," (".format_time($hi).")\n";elseif($sc&&$hb>1)echo"

    ".lang(152).": ".implode("",$sc)."\n";}else +echo"

    ".upload_error($I)."\n";}echo' +

    +';$xc="";if(!isset($_GET["import"])){$H=$_GET["sql"];if($_POST)$H=$_POST["query"];elseif($_GET["history"]=="all")$H=$td;elseif($_GET["history"]!="")$H=idx($td[$_GET["history"]],0);echo"

    ";textarea("query",$H,20);echo +script(($_POST?"":"qs('textarea').focus();\n")."qs('#form').onsubmit = partial(sqlSubmit, qs('#form'), '".js_escape(remove_from_uri("sql|limit|error_stops|only_errors|history"))."');"),"

    ";adminer()->sqlPrintAfter();echo"$xc\n",lang(159).": \n";}else{$jd=(extension_loaded("zlib")?"[.gz]":"");echo"

    ".lang(160)."
    ",file_input("SQL$jd: \n$xc"),"
    \n";$Dd=adminer()->importServerPath();if($Dd)echo"
    ".lang(161)."
    ",lang(162,"".h($Dd)."$jd"),' ',"
    \n";echo"

    ";}echo +checkbox("error_stops",1,($_POST?$_POST["error_stops"]:isset($_GET["import"])||$_GET["error_stops"]),lang(164))."\n",checkbox("only_errors",1,($_POST?$_POST["only_errors"]:isset($_GET["import"])||$_GET["only_errors"]),lang(165))."\n",input_token();if(!isset($_GET["import"])&&$td){print_fieldset("history",lang(166),$_GET["history"]!="");for($X=end($td);$X;$X=prev($td)){$y=key($td);list($H,$Wh,$fc)=$X;echo''.lang(12).""." ".@date("H:i:s",$Wh).""." ".shorten_utf8(ltrim(str_replace("\n"," ",str_replace("\r","",preg_replace("~^(#|$ye).*~m",'',$H)))),80,"").($fc?" ($fc)":"")."
    \n";}echo"\n","".lang(168)."\n","\n";}echo'

    +';}elseif(isset($_GET["edit"])){$a=$_GET["edit"];$m=fields($a);$Z=(isset($_GET["select"])?($_POST["check"]&&count($_POST["check"])==1?where_check($_POST["check"][0],$m):""):where($_GET,$m));$Ai=(isset($_GET["select"])?$_POST["edit"]:$Z);foreach($m +as$D=>$l){if((!$Ai&&!isset($l["privileges"]["insert"]))||adminer()->fieldName($l)=="")unset($m[$D]);}if($_POST&&!$k&&!isset($_GET["select"])){$A=$_POST["referer"];if($_POST["insert"])$A=($Ai?null:$_SERVER["REQUEST_URI"]);elseif(!preg_match('~^.+&select=.+$~',$A))$A=ME."select=".urlencode($a);$w=indexes($a);$wi=unique_array($_GET["where"],$w);$vg="\nWHERE $Z";if(isset($_POST["delete"]))queries_redirect($A,lang(169),driver()->delete($a,$vg,$wi?0:1));else{$P=array();foreach($m +as$D=>$l){$X=process_input($l);if($X!==false&&$X!==null)$P[idf_escape($D)]=$X;}if($Ai){if(!$P)redirect($A);queries_redirect($A,lang(170),driver()->update($a,$P,$vg,$wi?0:1));if(is_ajax()){page_headers();page_messages($k);exit;}}else{$J=driver()->insert($a,$P);$qe=($J?last_id($J):0);queries_redirect($A,lang(171,($qe?" $qe":"")),$J);}}}$L=null;if($Z){$N=array();foreach($m +as$D=>$l){if(isset($l["privileges"]["select"])){$va=($_POST["clone"]&&$l["auto_increment"]?"''":convert_field($l));$N[]=($va?"$va AS ":"").idf_escape($D);}}$L=array();if(!support("table"))$N=array("*");if($N){$J=driver()->select($a,$N,array($Z),$N,array(),(isset($_GET["select"])?2:1));if(!$J)$k=error();else{$L=$J->fetch_assoc();if(!$L)$L=false;}if(isset($_GET["select"])&&(!$L||$J->fetch_assoc()))$L=null;}}if(!support("table")&&!$m){if(!$Z){$J=driver()->select($a,array("*"),array(),array("*"));$L=($J?$J->fetch_assoc():false);if(!$L)$L=array(driver()->primary=>"");}if($L){foreach($L +as$y=>$X){if(!$Z)$L[$y]=null;$m[$y]=array("field"=>$y,"null"=>($y!=driver()->primary),"auto_increment"=>($y==driver()->primary));}}}if($_POST["save"])$L=(array)$_POST["fields"]+($L?$L:array());edit_form($a,$m,$L,$Ai,$k);}elseif(isset($_GET["create"])){$a=$_GET["create"];$Sf=driver()->partitionBy;$Wf=($Sf?driver()->partitionsInfo($a):array());$Ag=referencable_primary($a);$Xc=array();foreach($Ag +as$Gh=>$l)$Xc[str_replace("`","``",$Gh)."`".str_replace("`","``",$l["field"])]=$Gh;$Df=array();$S=array();if($a!=""){$Df=fields($a);$S=table_status1($a);if(count($S)<2)$k=lang(11);}$L=$_POST;$L["fields"]=(array)$L["fields"];if($L["auto_increment_col"])$L["fields"][$L["auto_increment_col"]]["auto_increment"]=true;if($_POST)save_settings(array("comments"=>$_POST["comments"],"defaults"=>$_POST["defaults"]));if($_POST&&!process_fields($L["fields"])&&!$k){if($_POST["drop"])queries_redirect(substr(ME,0,-1),lang(172),drop_tables(array($a)));else{$m=array();$qa=array();$Gi=false;$Vc=array();$Cf=reset($Df);$oa=" FIRST";foreach($L["fields"]as$y=>$l){$o=$Xc[$l["type"]];$si=($o!==null?$Ag[$o]:$l);if($l["field"]!=""){if(!$l["generated"])$l["default"]=null;$sg=process_field($l,$si);$qa[]=array($l["orig"],$sg,$oa);if(!$Cf||$sg!==process_field($Cf,$Cf)){$m[]=array($l["orig"],$sg,$oa);if($l["orig"]!=""||$oa)$Gi=true;}if($o!==null)$Vc[idf_escape($l["field"])]=($a!=""&&JUSH!="sqlite"?"ADD":" ").format_foreign_key(array('table'=>$Xc[$l["type"]],'source'=>array($l["field"]),'target'=>array($si["field"]),'on_delete'=>$l["on_delete"],));$oa=" AFTER ".idf_escape($l["field"]);}elseif($l["orig"]!=""){$Gi=true;$m[]=array($l["orig"]);}if($l["orig"]!=""){$Cf=next($Df);if(!$Cf)$oa="";}}$Uf=array();if(in_array($L["partition_by"],$Sf)){foreach($L +as$y=>$X){if(preg_match('~^partition~',$y))$Uf[$y]=$X;}foreach($Uf["partition_names"]as$y=>$D){if($D==""){unset($Uf["partition_names"][$y]);unset($Uf["partition_values"][$y]);}}$Uf["partition_names"]=array_values($Uf["partition_names"]);$Uf["partition_values"]=array_values($Uf["partition_values"]);if($Uf==$Wf)$Uf=array();}elseif(preg_match("~partitioned~",$S["Create_options"]))$Uf=null;$C=lang(173);if($a==""){cookie("adminer_engine",$L["Engine"]);$C=lang(174);}$D=trim($L["name"]);queries_redirect(ME.(support("table")?"table=":"select=").urlencode($D),$C,alter_table($a,$D,(JUSH=="sqlite"&&($Gi||$Vc)?$qa:$m),$Vc,($L["Comment"]!=$S["Comment"]?$L["Comment"]:null),($L["Engine"]&&$L["Engine"]!=$S["Engine"]?$L["Engine"]:""),($L["Collation"]&&$L["Collation"]!=$S["Collation"]?$L["Collation"]:""),($L["Auto_increment"]!=""?number($L["Auto_increment"]):""),$Uf));}}page_header(($a!=""?lang(42):lang(75)),$k,array("table"=>$a),h($a));if(!$_POST){$ti=driver()->types();$L=array("Engine"=>$_COOKIE["adminer_engine"],"fields"=>array(array("field"=>"","type"=>(isset($ti["int"])?"int":(isset($ti["integer"])?"integer":"")),"on_update"=>"")),"partition_names"=>array(""),);if($a!=""){$L=$S;$L["name"]=$a;$L["fields"]=array();if(!$_GET["auto_increment"])$L["Auto_increment"]="";foreach($Df +as$l){$l["generated"]=$l["generated"]?:(isset($l["default"])?"DEFAULT":"");$L["fields"][]=$l;}if($Sf){$L+=$Wf;$L["partition_names"][]="";$L["partition_values"][]="";}}}$eb=collations();if(is_array(reset($eb)))$eb=call_user_func_array('array_merge',array_values($eb));$mc=driver()->engines();foreach($mc +as$lc){if(!strcasecmp($lc,$L["Engine"])){$L["Engine"]=$lc;break;}}echo' +
    +

    +';if(support("columns")||$a==""){echo +lang(175).": \n",($mc?html_select("Engine",array(""=>"(".lang(176).")")+$mc,$L["Engine"]).on_help("event.target.value",1).script("qsl('select').onchange = helpClose;")."\n":"");if($eb)echo"".optionlist($eb)."\n",(preg_match("~sqlite|mssql~",JUSH)?"":"\n");echo"\n";}if(support("columns")){echo"

    \n","\n";edit_fields($L["fields"],$eb,"TABLE",$Xc);echo"
    \n",script("editFields();"),"
    \n

    \n",lang(49).": \n",checkbox("defaults",1,($_POST?$_POST["defaults"]:get_setting("defaults")),lang(177),"columnShow(this.checked, 5)","jsonly");$kb=($_POST?$_POST["comments"]:get_setting("comments"));echo(support("comment")?checkbox("comments",1,$kb,lang(48),"editingCommentsClick(this, true);","jsonly").' '.(preg_match('~\n~',$L["Comment"])?"":''):''),'

    + +';}echo' +';if($a!="")echo'',confirm(lang(178,$a));if($Sf&&(JUSH=='sql'||$a=="")){$Tf=preg_match('~RANGE|LIST~',$L["partition_by"]);print_fieldset("partition",lang(179),$L["partition_by"]);echo"

    ".html_select("partition_by",array_merge(array(""),$Sf),$L["partition_by"]).on_help("event.target.value.replace(/./, 'PARTITION BY \$&')",1).script("qsl('select').onchange = partitionByChange;"),"()\n",lang(180).": \n","\n","\n";foreach($L["partition_names"]as$y=>$X)echo'','\n\n";}echo +input_token(),'

    +';}elseif(isset($_GET["indexes"])){$a=$_GET["indexes"];$Ld=array("PRIMARY","UNIQUE","INDEX");$S=table_status1($a,true);$Id=driver()->indexAlgorithms($S);if(preg_match('~MyISAM|M?aria'.(min_version(5.6,'10.0.5')?'|InnoDB':'').'~i',$S["Engine"]))$Ld[]="FULLTEXT";if(preg_match('~MyISAM|M?aria'.(min_version(5.7,'10.2.2')?'|InnoDB':'').'~i',$S["Engine"]))$Ld[]="SPATIAL";$w=indexes($a);$m=fields($a);$ng=array();if(JUSH=="mongo"){$ng=$w["_id_"];unset($Ld[0]);unset($w["_id_"]);}$L=$_POST;if($L)save_settings(array("index_options"=>$L["options"]));if($_POST&&!$k&&!$_POST["add"]&&!$_POST["drop_col"]){$ra=array();foreach($L["indexes"]as$v){$D=$v["name"];if(in_array($v["type"],$Ld)){$d=array();$we=array();$Rb=array();$Jd=(support("partial_indexes")?$v["partial"]:"");$Hd=(in_array($v["algorithm"],$Id)?$v["algorithm"]:"");$P=array();ksort($v["columns"]);foreach($v["columns"]as$y=>$c){if($c!=""){$ve=idx($v["lengths"],$y);$Pb=idx($v["descs"],$y);$P[]=($m[$c]?idf_escape($c):$c).($ve?"(".(+$ve).")":"").($Pb?" DESC":"");$d[]=$c;$we[]=($ve?:null);$Rb[]=$Pb;}}$yc=$w[$D];if($yc){ksort($yc["columns"]);ksort($yc["lengths"]);ksort($yc["descs"]);if($v["type"]==$yc["type"]&&array_values($yc["columns"])===$d&&(!$yc["lengths"]||array_values($yc["lengths"])===$we)&&array_values($yc["descs"])===$Rb&&$yc["partial"]==$Jd&&(!$Id||$yc["algorithm"]==$Hd)){unset($w[$D]);continue;}}if($d)$ra[]=array($v["type"],$D,$P,$Hd,$Jd);}}foreach($w +as$D=>$yc)$ra[]=array($yc["type"],$D,"DROP");if(!$ra)redirect(ME."table=".urlencode($a));queries_redirect(ME."table=".urlencode($a),lang(183),alter_indexes($a,$ra));}page_header(lang(136),$k,array("table"=>$a),h($a));$Kc=array_keys($m);if($_POST["add"]){foreach($L["indexes"]as$y=>$v){if($v["columns"][count($v["columns"])]!="")$L["indexes"][$y]["columns"][]="";}$v=end($L["indexes"]);if($v["type"]||array_filter($v["columns"],'strlen'))$L["indexes"][]=array("columns"=>array(1=>""));}if(!$L){foreach($w +as$y=>$v){$w[$y]["name"]=$y;$w[$y]["columns"][]="";}$w[]=array("columns"=>array(1=>""));$L["indexes"]=$w;}$we=(JUSH=="sql"||JUSH=="mssql");$kh=($_POST?$_POST["options"]:get_setting("index_options"));echo' +
    +
    + + + +';if($ng){echo"
    ',lang(184);$Bd=" class='idxopts".($kh?"":" hidden")."'";if($Id)echo"".lang(185).doc_link(array('pgsql'=>'indexes-types.html',));echo'',lang(186).($we?" (".lang(187).")":"");if($we||support("descidx"))echo +checkbox("options",1,$kh,lang(108),"indexOptionsShow(this.checked)","jsonly")."\n";echo'',lang(188);if(support("partial_indexes"))echo"".lang(189);echo' +
    PRIMARY";foreach($ng["columns"]as$y=>$c)echo +select_input(" disabled",$Kc,$c)," ";echo"\n";}$x=1;foreach($L["indexes"]as$v){if(!$_POST["drop_col"]||$x!=key($_POST["drop_col"])){echo"
    ".html_select("indexes[$x][type]",array(-1=>"")+$Ld,$v["type"],($x==count($L["indexes"])?"indexesAddRow.call(this);":""),"label-type");if($Id)echo"".html_select("indexes[$x][algorithm]",array_merge(array(""),$Id),$v['algorithm'],"label-algorithm");echo"";ksort($v["columns"]);$s=1;foreach($v["columns"]as$y=>$c){echo"".select_input(" name='indexes[$x][columns][$s]' title='".lang(46)."'",($m&&($c==""||$m[$c])?array_combine($Kc,$Kc):array()),$c,"partial(".($s==count($v["columns"])?"indexesAddColumn":"indexesChangeColumn").", '".js_escape(JUSH=="sql"?"":$_GET["indexes"]."_")."')"),"",($we?"":""),(support("descidx")?checkbox("indexes[$x][descs][$s]",1,idx($v["descs"],$y),lang(57)):"")," ";$s++;}echo"\n";if(support("partial_indexes"))echo"\n";echo"".icon("cross","drop_col[$x]","x",lang(112)).script("qsl('button').onclick = partial(editingRemoveRow, 'indexes\$1[type]');");}$x++;}echo'
    +
    +

    + +',input_token(),'

    +';}elseif(isset($_GET["database"])){$L=$_POST;if($_POST&&!$k&&!$_POST["add"]){$D=trim($L["name"]);if($_POST["drop"]){$_GET["db"]="";queries_redirect(remove_from_uri("db|database"),lang(190),drop_databases(array(DB)));}elseif(DB!==$D){if(DB!=""){$_GET["db"]=$D;queries_redirect(preg_replace('~\bdb=[^&]*&~','',ME)."db=".urlencode($D),lang(191),rename_database($D,$L["collation"]));}else{$i=explode("\n",str_replace("\r","",$D));$Ah=true;$pe="";foreach($i +as$j){if(count($i)==1||$j!=""){if(!create_database($j,$L["collation"]))$Ah=false;$pe=$j;}}restart_session();set_session("dbs",null);queries_redirect(ME."db=".urlencode($pe),lang(192),$Ah);}}else{if(!$L["collation"])redirect(substr(ME,0,-1));query_redirect("ALTER DATABASE ".idf_escape($D).(preg_match('~^[a-z0-9_]+$~i',$L["collation"])?" COLLATE $L[collation]":""),substr(ME,0,-1),lang(193));}}page_header(DB!=""?lang(65):lang(116),$k,array(),h(DB));$eb=collations();$D=DB;if($_POST)$D=$L["name"];elseif(DB!="")$L["collation"]=db_collation(DB,$eb);elseif(JUSH=="sql"){foreach(get_vals("SHOW GRANTS")as$fd){if(preg_match('~ ON (`(([^\\\\`]|``|\\\\.)*)%`\.\*)?~',$fd,$B)&&$B[1]){$D=stripcslashes(idf_unescape("`$B[2]`"));break;}}}echo' +
    +

    +',($_POST["add"]||strpos($D,"\n")?'
    ':'')."\n".($eb?html_select("collation",array(""=>"(".lang(102).")")+$eb,$L["collation"]).'':""),' +';if(DB!="")echo"".confirm(lang(178,DB))."\n";elseif(!$_POST["add"]&&$_GET["db"]=="")echo +icon("plus","add[0]","+",lang(109))."\n";echo +input_token(),'

    +';}elseif(isset($_GET["scheme"])){$L=$_POST;if($_POST&&!$k){$_=preg_replace('~ns=[^&]*&~','',ME)."ns=";if($_POST["drop"])query_redirect("DROP SCHEMA ".idf_escape($_GET["ns"]),$_,lang(194));else{$D=trim($L["name"]);$_ +.=urlencode($D);if($_GET["ns"]=="")query_redirect("CREATE SCHEMA ".idf_escape($D),$_,lang(195));elseif($_GET["ns"]!=$D)query_redirect("ALTER SCHEMA ".idf_escape($_GET["ns"])." RENAME TO ".idf_escape($D),$_,lang(196));else +redirect($_);}}page_header($_GET["ns"]!=""?lang(66):lang(67),$k);if(!$L)$L["name"]=$_GET["ns"];echo' +
    +

    + +';if($_GET["ns"]!="")echo"".confirm(lang(178,$_GET["ns"]))."\n";echo +input_token(),'

    +';}elseif(isset($_GET["call"])){$ba=($_GET["name"]?:$_GET["call"]);page_header(lang(197).": ".h($ba),$k);$Mg=routine($_GET["call"],(isset($_GET["callf"])?"FUNCTION":"PROCEDURE"));$Ed=array();$Hf=array();foreach($Mg["fields"]as$s=>$l){if(substr($l["inout"],-3)=="OUT"&&JUSH=='sql')$Hf[$s]="@".idf_escape($l["field"])." AS ".idf_escape($l["field"]);if(!$l["inout"]||substr($l["inout"],0,2)=="IN")$Ed[]=$s;}if(!$k&&$_POST){$Oa=array();foreach($Mg["fields"]as$y=>$l){$X="";if(in_array($y,$Ed)){$X=process_input($l);if($X===false)$X="''";if(isset($Hf[$y]))connection()->query("SET @".idf_escape($l["field"])." = $X");}if(isset($Hf[$y]))$Oa[]="@".idf_escape($l["field"]);elseif(in_array($y,$Ed))$Oa[]=$X;}$I=(isset($_GET["callf"])?"SELECT ":"CALL ").(idx($Mg["returns"],"type")=="record"?"* FROM ":"").table($ba)."(".implode(", ",$Oa).")";$wh=microtime(true);$J=connection()->multi_query($I);$ma=connection()->affected_rows;echo +adminer()->selectQuery($I,$wh,!$J);if(!$J)echo"

    ".error()."\n";else{$g=connect();if($g)$g->select_db(DB);do{$J=connection()->store_result();if(is_object($J))print_select_result($J,$g);else +echo"

    ".lang(198,$ma)." ".@date("H:i:s")."\n";}while(connection()->next_result());if($Hf)print_select_result(connection()->query("SELECT ".implode(", ",$Hf)));}}echo' +

    +';if($Ed){echo"\n";foreach($Ed +as$y){$l=$Mg["fields"][$y];$D=$l["field"];echo"
    ".adminer()->fieldName($l);$Y=idx($_POST["fields"],$D);if($Y!=""){if($l["type"]=="set")$Y=implode(",",$Y);}input($l,$Y,idx($_POST["function"],$D,""));echo"\n";}echo"
    \n";}echo'

    + +',input_token(),'

    + +
    +';function
    +pre_tr($Pg){return
    +preg_replace('~^~m','',preg_replace('~\|~','',preg_replace('~\|$~m',"",rtrim($Pg))));}$R='(\+--[-+]+\+\n)';$L='(\| .* \|\n)';echo
    +preg_replace_callback("~^$R?$L$R?($L*)$R?~m",function($B){$Oc=pre_tr($B[2]);return"\n".($B[1]?"$Oc\n":$Oc).pre_tr($B[4])."\n
    ";},preg_replace('~(\n( -|mysql)> )(.+)~',"\\1\\3",preg_replace('~(.+)\n---+\n~',"\\1\n",h($Mg['comment']))));echo'
    +';}elseif(isset($_GET["foreign"])){$a=$_GET["foreign"];$D=$_GET["name"];$L=$_POST;if($_POST&&!$k&&!$_POST["add"]&&!$_POST["change"]&&!$_POST["change-js"]){if(!$_POST["drop"]){$L["source"]=array_filter($L["source"],'strlen');ksort($L["source"]);$Ph=array();foreach($L["source"]as$y=>$X)$Ph[$y]=$L["target"][$y];$L["target"]=$Ph;}if(JUSH=="sqlite")$J=recreate_table($a,$a,array(),array(),array(" $D"=>($L["drop"]?"":" ".format_foreign_key($L))));else{$ra="ALTER TABLE ".table($a);$J=($D==""||queries("$ra DROP ".(JUSH=="sql"?"FOREIGN KEY ":"CONSTRAINT ").idf_escape($D)));if(!$L["drop"])$J=queries("$ra ADD".format_foreign_key($L));}queries_redirect(ME."table=".urlencode($a),($L["drop"]?lang(199):($D!=""?lang(200):lang(201))),$J);if(!$L["drop"])$k=lang(202);}page_header(lang(203),$k,array("table"=>$a),h($a));if($_POST){ksort($L["source"]);if($_POST["add"])$L["source"][]="";elseif($_POST["change"]||$_POST["change-js"])$L["target"]=array();}elseif($D!=""){$Xc=foreign_keys($a);$L=$Xc[$D];$L["source"][]="";}else{$L["table"]=$a;$L["source"]=array("");}echo' +
    +';$ph=array_keys(fields($a));if($L["db"]!="")connection()->select_db($L["db"]);if($L["ns"]!=""){$Ef=get_schema();set_schema($L["ns"]);}$_g=array_keys(array_filter(table_status('',true),'Adminer\fk_support'));$Ph=array_keys(fields(in_array($L["table"],$_g)?$L["table"]:reset($_g)));$qf="this.form['change-js'].value = '1'; this.form.submit();";echo"

    \n";if(support("scheme")){$Rg=array_filter(adminer()->schemas(),function($Qg){return!preg_match('~^information_schema$~i',$Qg);});echo"";if($L["ns"]!="")set_schema($Ef);}elseif(JUSH!="sqlite"){$Hb=array();foreach(adminer()->databases()as$j){if(!information_schema($j))$Hb[]=$j;}echo"";}echo +input_hidden("change-js"),'

    + + +';$x=0;foreach($L["source"]as$y=>$X){echo"","
    ',lang(138),'',lang(139),'
    ".html_select("source[".(+$y)."]",array(-1=>"")+$ph,$X,($x==count($L["source"])-1?"foreignAddRow.call(this);":""),"label-source"),"".html_select("target[".(+$y)."]",$Ph,idx($L["target"],$y),"","label-target");$x++;}echo'
    +

    + + +',(DRIVER==='pgsql'?html_select("deferrable",array('NOT DEFERRABLE','DEFERRABLE','DEFERRABLE INITIALLY DEFERRED'),$L["deferrable"]).' ':''),doc_link(array('pgsql'=>"sql-createtable.html#SQL-CREATETABLE-PARMS-REFERENCES",)),'

    + +

    +';if($D!="")echo'',confirm(lang(178,$D));echo +input_token(),'

    +';}elseif(isset($_GET["view"])){$a=$_GET["view"];$L=$_POST;$Ff="VIEW";if(JUSH=="pgsql"&&$a!=""){$Q=table_status1($a);$Ff=strtoupper($Q["Engine"]);}if($_POST&&!$k){$D=trim($L["name"]);$va=" AS\n$L[select]";$A=ME."table=".urlencode($D);$C=lang(207);$U=($_POST["materialized"]?"MATERIALIZED VIEW":"VIEW");if(!$_POST["drop"]&&$a==$D&&JUSH!="sqlite"&&$U=="VIEW"&&$Ff=="VIEW")query_redirect((JUSH=="mssql"?"ALTER":"CREATE OR REPLACE")." VIEW ".table($D).$va,$A,$C);else{$Rh=$D."_adminer_".uniqid();drop_create("DROP $Ff ".table($a),"CREATE $U ".table($D).$va,"DROP $U ".table($D),"CREATE $U ".table($Rh).$va,"DROP $U ".table($Rh),($_POST["drop"]?substr(ME,0,-1):$A),lang(208),$C,lang(209),$a,$D);}}if(!$_POST&&$a!=""){$L=view($a);$L["name"]=$a;$L["materialized"]=($Ff!="VIEW");if(!$k)$k=error();}page_header(($a!=""?lang(41):lang(210)),$k,array("table"=>$a),h($a));echo' +
    +

    ',lang(188),': +',(support("materializedview")?" ".checkbox("materialized",1,$L["materialized"],lang(132)):""),'

    ';textarea("select",$L["select"]);echo'

    + +';if($a!="")echo'',confirm(lang(178,$a));echo +input_token(),'

    +';}elseif(isset($_GET["procedure"])){$ba=($_GET["name"]?:$_GET["procedure"]);$Mg=(isset($_GET["function"])?"FUNCTION":"PROCEDURE");$L=$_POST;$L["fields"]=(array)$L["fields"];if($_POST&&!process_fields($L["fields"])&&!$k){$Bf=routine($_GET["procedure"],$Mg);$Rh="$L[name]_adminer_".uniqid();foreach($L["fields"]as$y=>$l){if($l["field"]=="")unset($L["fields"][$y]);}drop_create("DROP $Mg ".routine_id($ba,$Bf),create_routine($Mg,$L),"DROP $Mg ".routine_id($L["name"],$L),create_routine($Mg,array("name"=>$Rh)+$L),"DROP $Mg ".routine_id($Rh,$L),substr(ME,0,-1),lang(211),lang(212),lang(213),$ba,$L["name"]);}page_header(($ba!=""?(isset($_GET["function"])?lang(214):lang(215)).": ".h($ba):(isset($_GET["function"])?lang(216):lang(217))),$k);if(!$_POST){if($ba=="")$L["language"]="sql";else{$L=routine($_GET["procedure"],$Mg);$L["name"]=$ba;}}$eb=get_vals("SHOW CHARACTER SET");sort($eb);$Ng=routine_languages();echo($eb?"".optionlist($eb)."":""),' +
    +

    ',lang(188),': +',($Ng?"\n":""),' +

    + +';edit_fields($L["fields"],$eb,$Mg);if(isset($_GET["function"])){echo"
    ".lang(218);edit_type("returns",(array)$L["returns"],$eb,array(),(JUSH=="pgsql"?array("void","trigger"):array()));}echo'
    +',script("editFields();"),'
    +

    ';textarea("definition",$L["definition"],20);echo'

    + +';if($ba!="")echo'',confirm(lang(178,$ba));echo +input_token(),'

    +';}elseif(isset($_GET["sequence"])){$da=$_GET["sequence"];$L=$_POST;if($_POST&&!$k){$_=substr(ME,0,-1);$D=trim($L["name"]);if($_POST["drop"])query_redirect("DROP SEQUENCE ".idf_escape($da),$_,lang(219));elseif($da=="")query_redirect("CREATE SEQUENCE ".idf_escape($D),$_,lang(220));elseif($da!=$D)query_redirect("ALTER SEQUENCE ".idf_escape($da)." RENAME TO ".idf_escape($D),$_,lang(221));else +redirect($_);}page_header($da!=""?lang(222).": ".h($da):lang(223),$k);if(!$L)$L["name"]=$da;echo' +
    +

    + +';if($da!="")echo"".confirm(lang(178,$da))."\n";echo +input_token(),'

    +';}elseif(isset($_GET["type"])){$ea=$_GET["type"];$L=$_POST;if($_POST&&!$k){$_=substr(ME,0,-1);if($_POST["drop"])query_redirect("DROP TYPE ".idf_escape($ea),$_,lang(224));else +query_redirect("CREATE TYPE ".idf_escape(trim($L["name"]))." $L[as]",$_,lang(225));}page_header($ea!=""?lang(226).": ".h($ea):lang(227),$k);if(!$L)$L["as"]="AS ";echo' +
    +

    +';if($ea!=""){$ti=driver()->types();$qc=type_values($ti[$ea]);if($qc)echo"ENUM (".h($qc).")\n

    ";echo"".confirm(lang(178,$ea))."\n";}else{echo +lang(188).": \n",doc_link(array('pgsql'=>"datatype-enum.html",),"?");textarea("as",$L["as"]);echo"

    \n";}echo +input_token(),'

    +';}elseif(isset($_GET["check"])){$a=$_GET["check"];$D=$_GET["name"];$L=$_POST;if($L&&!$k){if(JUSH=="sqlite")$J=recreate_table($a,$a,array(),array(),array(),"",array(),"$D",($L["drop"]?"":$L["clause"]));else{$J=($D==""||queries("ALTER TABLE ".table($a)." DROP CONSTRAINT ".idf_escape($D)));if(!$L["drop"])$J=queries("ALTER TABLE ".table($a)." ADD".($L["name"]!=""?" CONSTRAINT ".idf_escape($L["name"]):"")." CHECK ($L[clause])");}queries_redirect(ME."table=".urlencode($a),($L["drop"]?lang(228):($D!=""?lang(229):lang(230))),$J);}page_header(($D!=""?lang(231).": ".h($D):lang(143)),$k,array("table"=>$a));if(!$L){$Ua=driver()->checkConstraints($a);$L=array("name"=>$D,"clause"=>$Ua[$D]);}echo' +
    +

    ';if(JUSH!="sqlite")echo +lang(188).': ';echo +doc_link(array('pgsql'=>"ddl-constraints.html#DDL-CONSTRAINTS-CHECK-CONSTRAINTS",),"?"),'

    ';textarea("clause",$L["clause"]);echo'

    +';if($D!="")echo'',confirm(lang(178,$D));echo +input_token(),'

    +';}elseif(isset($_GET["trigger"])){$a=$_GET["trigger"];$D="$_GET[name]";$pi=trigger_options();$L=(array)trigger($D,$a)+array("Trigger"=>$a."_bi");if($_POST){if(!$k&&in_array($_POST["Timing"],$pi["Timing"])&&in_array($_POST["Event"],$pi["Event"])&&in_array($_POST["Type"],$pi["Type"])){$of=" ON ".table($a);$Yb="DROP TRIGGER ".idf_escape($D).(JUSH=="pgsql"?$of:"");$A=ME."table=".urlencode($a);if($_POST["drop"])query_redirect($Yb,$A,lang(232));else{if($D!="")queries($Yb);queries_redirect($A,($D!=""?lang(233):lang(234)),queries(create_trigger($of,$_POST)));if($D!="")queries(create_trigger($of,$L+array("Type"=>reset($pi["Type"]))));}}$L=$_POST;}page_header(($D!=""?lang(235).": ".h($D):lang(236)),$k,array("table"=>$a));echo' +
    + +
    ',lang(237),'',html_select("Timing",$pi["Timing"],$L["Timing"],"triggerChange(/^".preg_quote($a,"/")."_[ba][iud]$/, '".js_escape($a)."', this.form);"),'
    ',lang(238),'',html_select("Event",$pi["Event"],$L["Event"],"this.form['Timing'].onchange();"),(in_array("UPDATE OF",$pi["Event"])?" ":""),'
    ',lang(47),'',html_select("Type",$pi["Type"],$L["Type"]),'
    +

    ',lang(188),': +',script("qs('#form')['Timing'].onchange();"),'

    ';textarea("Statement",$L["Statement"]);echo'

    + +';if($D!="")echo'',confirm(lang(178,$D));echo +input_token(),'

    +';}elseif(isset($_GET["processlist"])){if(support("kill")){if($_POST&&!$k){$ke=0;foreach((array)$_POST["kill"]as$X){if(adminer()->killProcess($X))$ke++;}queries_redirect(ME."processlist=",lang(239,$ke),$ke||!$_POST["kill"]);}}page_header(lang(117),$k);echo' +
    +
    + +',script("mixin(qsl('table'), {onclick: tableClick, ondblclick: partialArg(tableClick, true)});");$s=-1;foreach(adminer()->processList()as$s=>$L){if(!$s){echo"".(support("kill")?"\n";}echo"".(support("kill")?"
    ":"");foreach($L +as$y=>$X)echo"$y".doc_link(array('pgsql'=>"monitoring-stats.html#PG-STAT-ACTIVITY-VIEW",));echo"
    ".checkbox("kill[]",$L[JUSH=="sql"?"Id":"pid"],0):"");foreach($L +as$y=>$X)echo"".((JUSH=="sql"&&$y=="Info"&&preg_match("~Query|Killed~",$L["Command"])&&$X!="")||(JUSH=="pgsql"&&$y=="current_query"&&$X!="")||(JUSH=="oracle"&&$y=="sql_text"&&$X!="")?"".shorten_utf8($X,100,"").' '.lang(240).'':h($X));echo"\n";}echo'
    +
    +

    +';if(support("kill"))echo($s+1)."/".lang(241,max_connections()),"

    \n";echo +input_token(),'

    +',script("tableCheck();");}elseif(isset($_GET["select"])){$a=$_GET["select"];$S=table_status1($a);$w=indexes($a);$m=fields($a);$Xc=column_foreign_keys($a);$mf=$S["Oid"];$la=get_settings("adminer_import");$Kg=array();$d=array();$Vg=array();$yf=array();$Vh="";foreach($m +as$y=>$l){$D=adminer()->fieldName($l);$cf=html_entity_decode(strip_tags($D),ENT_QUOTES);if(isset($l["privileges"]["select"])&&$D!=""){$d[$y]=$cf;if(is_shortable($l))$Vh=adminer()->selectLengthProcess();}if(isset($l["privileges"]["where"])&&$D!="")$Vg[$y]=$cf;if(isset($l["privileges"]["order"])&&$D!="")$yf[$y]=$cf;$Kg+=$l["privileges"];}list($N,$r)=adminer()->selectColumnsProcess($d,$w);$N=array_unique($N);$r=array_unique($r);$be=count($r)selectSearchProcess($m,$w);$xf=adminer()->selectOrderProcess($m,$w);$z=adminer()->selectLimitProcess();if($_GET["val"]&&is_ajax()){header("Content-Type: text/plain; charset=utf-8");foreach($_GET["val"]as$xi=>$L){$va=convert_field($m[key($L)]);$N=array($va?:idf_escape(key($L)));$Z[]=where_check($xi,$m);$K=driver()->select($a,$N,$Z,$N);if($K)echo +first($K->fetch_row());}exit;}$ng=$zi=array();foreach($w +as$v){if($v["type"]=="PRIMARY"){$ng=array_flip($v["columns"]);$zi=($N?$ng:array());foreach($zi +as$y=>$X){if(in_array(idf_escape($y),$N))unset($zi[$y]);}break;}}if($mf&&!$ng){$ng=$zi=array($mf=>0);$w[]=array("type"=>"PRIMARY","columns"=>array($mf));}if($_POST&&!$k){$Zi=$Z;if(!$_POST["all"]&&is_array($_POST["check"])){$Ua=array();foreach($_POST["check"]as$Qa)$Ua[]=where_check($Qa,$m);$Zi[]="((".implode(") OR (",$Ua)."))";}$Zi=($Zi?"\nWHERE ".implode(" AND ",$Zi):"");if($_POST["export"]){save_settings(array("output"=>$_POST["output"],"format"=>$_POST["format"]),"adminer_import");dump_headers($a);adminer()->dumpTable($a,"");$bd=($N?implode(", ",$N):"*").convert_fields($d,$m,$N)."\nFROM ".table($a);$hd=($r&&$be?"\nGROUP BY ".implode(", ",$r):"").($xf?"\nORDER BY ".implode(", ",$xf):"");$I="SELECT $bd$Zi$hd";if(is_array($_POST["check"])&&!$ng){$vi=array();foreach($_POST["check"]as$X)$vi[]="(SELECT".limit($bd,"\nWHERE ".($Z?implode(" AND ",$Z)." AND ":"").where_check($X,$m).$hd,1).")";$I=implode(" UNION ALL ",$vi);}adminer()->dumpData($a,"table",$I);adminer()->dumpFooter();exit;}if(!adminer()->selectEmailProcess($Z,$Xc)){if($_POST["save"]||$_POST["delete"]){$J=true;$ma=0;$P=array();if(!$_POST["delete"]){foreach($_POST["fields"]as$D=>$X){$X=process_input($m[$D]);if($X!==null&&($_POST["clone"]||$X!==false))$P[idf_escape($D)]=($X!==false?$X:idf_escape($D));}}if($_POST["delete"]||$P){$I=($_POST["clone"]?"INTO ".table($a)." (".implode(", ",array_keys($P)).")\nSELECT ".implode(", ",$P)."\nFROM ".table($a):"");if($_POST["all"]||($ng&&is_array($_POST["check"]))||$be){$J=($_POST["delete"]?driver()->delete($a,$Zi):($_POST["clone"]?queries("INSERT $I$Zi".driver()->insertReturning($a)):driver()->update($a,$P,$Zi)));$ma=connection()->affected_rows;if(is_object($J))$ma+=$J->num_rows;}else{foreach((array)$_POST["check"]as$X){$Yi="\nWHERE ".($Z?implode(" AND ",$Z)." AND ":"").where_check($X,$m);$J=($_POST["delete"]?driver()->delete($a,$Yi,1):($_POST["clone"]?queries("INSERT".limit1($a,$I,$Yi)):driver()->update($a,$P,$Yi,1)));if(!$J)break;$ma+=connection()->affected_rows;}}}$C=lang(243,$ma);if($_POST["clone"]&&$J&&$ma==1){$qe=last_id($J);if($qe)$C=lang(171," $qe");}queries_redirect(remove_from_uri($_POST["all"]&&$_POST["delete"]?"page":""),$C,$J);if(!$_POST["delete"]){$ig=(array)$_POST["fields"];edit_form($a,array_intersect_key($m,$ig),$ig,!$_POST["clone"],$k);page_footer();exit;}}elseif(!$_POST["import"]){if(!$_POST["val"])$k=lang(244);else{$J=true;$ma=0;foreach($_POST["val"]as$xi=>$L){$P=array();foreach($L +as$y=>$X){$y=bracket_escape($y,true);$P[idf_escape($y)]=(preg_match('~char|text~',$m[$y]["type"])||$X!=""?adminer()->processInput($m[$y],$X):"NULL");}$J=driver()->update($a,$P," WHERE ".($Z?implode(" AND ",$Z)." AND ":"").where_check($xi,$m),($be||$ng?0:1)," ");if(!$J)break;$ma+=connection()->affected_rows;}queries_redirect(remove_from_uri(),lang(243,$ma),$J);}}elseif(!is_string($Lc=get_file("csv_file",true)))$k=upload_error($Lc);elseif(!preg_match('~~u',$Lc))$k=lang(245);else{save_settings(array("output"=>$la["output"],"format"=>$_POST["separator"]),"adminer_import");$J=true;$fb=array_keys($m);preg_match_all('~(?>"[^"]*"|[^"\r\n]+)+~',$Lc,$De);$ma=count($De[0]);driver()->begin();$bh=($_POST["separator"]=="csv"?",":($_POST["separator"]=="tsv"?"\t":";"));$M=array();foreach($De[0]as$y=>$X){preg_match_all("~((?>\"[^\"]*\")+|[^$bh]*)$bh~",$X.$bh,$Ee);if(!$y&&!array_diff($Ee[1],$fb)){$fb=$Ee[1];$ma--;}else{$P=array();foreach($Ee[1]as$s=>$bb)$P[idf_escape($fb[$s])]=($bb==""&&$m[$fb[$s]]["null"]?"NULL":q(preg_match('~^".*"$~s',$bb)?str_replace('""','"',substr($bb,1,-1)):$bb));$M[]=$P;}}$J=(!$M||driver()->insertUpdate($a,$M,$ng));if($J)driver()->commit();queries_redirect(remove_from_uri("page"),lang(246,$ma),$J);driver()->rollback();}}}$Gh=adminer()->tableName($S);if(is_ajax()){page_headers();ob_start();}else +page_header(lang(51).": $Gh",$k);$P=null;if(isset($Kg["insert"])||!support("table")){$Mf=array();foreach((array)$_GET["where"]as$X){if(isset($Xc[$X["col"]])&&count($Xc[$X["col"]])==1&&($X["op"]=="="||(!$X["op"]&&(is_array($X["val"])||!preg_match('~[_%]~',$X["val"])))))$Mf["set"."[".bracket_escape($X["col"])."]"]=$X["val"];}$P=$Mf?"&".http_build_query($Mf):"";}adminer()->selectLinks($S,$P);if(!$d&&support("table"))echo"

    ".lang(247).($m?".":": ".error())."\n";else{echo"

    \n","
    ";hidden_fields_get();echo(DB!=""?input_hidden("db",DB).(isset($_GET["ns"])?input_hidden("ns",$_GET["ns"]):""):""),input_hidden("select",$a),"
    \n";adminer()->selectColumnsPrint($N,$d);adminer()->selectSearchPrint($Z,$Vg,$w);adminer()->selectOrderPrint($xf,$yf,$w);adminer()->selectLimitPrint($z);adminer()->selectLengthPrint($Vh);adminer()->selectActionPrint($w);echo"
    \n";$F=$_GET["page"];$ad=null;if($F=="last"){$ad=get_val(count_rows($a,$Z,$be,$r));$F=floor(max(0,intval($ad)-1)/$z);}$Wg=$N;$gd=$r;if(!$Wg){$Wg[]="*";$tb=convert_fields($d,$m,$N);if($tb)$Wg[]=substr($tb,2);}foreach($N +as$y=>$X){$l=$m[idf_unescape($X)];if($l&&($va=convert_field($l)))$Wg[$y]="$va AS $X";}if(!$be&&$zi){foreach($zi +as$y=>$X){$Wg[]=idf_escape($y);if($gd)$gd[]=idf_escape($y);}}$J=driver()->select($a,$Wg,$Z,$gd,$xf,$z,$F,true);if(!$J)echo"

    ".error()."\n";else{if(JUSH=="mssql"&&$F)$J->seek($z*$F);$jc=array();echo"

    \n";$M=array();while($L=$J->fetch_assoc()){if($F&&JUSH=="oracle")unset($L["RNUM"]);$M[]=$L;}if($_GET["page"]!="last"&&$z&&$r&&$be&&JUSH=="sql")$ad=get_val(" SELECT FOUND_ROWS()");if(!$M)echo"

    ".lang(14)."\n";else{$Ba=adminer()->backwardKeys($a,$Gh);echo"

    ","",script("mixin(qs('#table'), {onclick: tableClick, ondblclick: partialArg(tableClick, true), onkeydown: editingKeydown});"),"".(!$r&&$N?"":"\n";if(is_ajax())ob_end_clean();foreach(adminer()->rowDescriptions($M,$Xc)as$bf=>$L){$wi=unique_array($M[$bf],$w);if(!$wi){$wi=array();reset($N);foreach($M[$bf]as$y=>$X){if(!preg_match('~^(COUNT|AVG|GROUP_CONCAT|MAX|MIN|SUM)\(~',current($N)))$wi[$y]=$X;next($N);}}$xi="";foreach($wi +as$y=>$X){$l=(array)$m[$y];if((JUSH=="sql"||JUSH=="pgsql")&&preg_match('~char|text|enum|set~',$l["type"])&&strlen($X)>64){$y=(strpos($y,'(')?$y:idf_escape($y));$y="MD5(".(JUSH!='sql'||preg_match("~^utf8~",$l["collation"])?$y:"CONVERT($y USING ".charset(connection()).")").")";$X=md5($X);}$xi +.="&".($X!==null?urlencode("where[".bracket_escape($y)."]")."=".urlencode($X===false?"f":$X):"null%5B%5D=".urlencode($y));}echo"".(!$r&&$N?"":"\n";}if(is_ajax())exit;echo"
    ".script("qs('#all-page').onclick = partial(formCheck, /check/);","")." ".lang(248)."");$df=array();$dd=array();reset($N);$xg=1;foreach($M[0]as$y=>$X){if(!isset($zi[$y])){$X=idx($_GET["columns"],key($N))?:array();$l=$m[$N?($X?$X["col"]:current($N)):$y];$D=($l?adminer()->fieldName($l,$xg):($X["fun"]?"*":h($y)));if($D!=""){$xg++;$df[$y]=$D;$c=idf_escape($y);$xd=remove_from_uri('(order|desc)[^=]*|page').'&order%5B0%5D='.urlencode($y);$Pb="&desc%5B0%5D=1";echo"".script("mixin(qsl('th'), {onmouseover: partial(columnMouse), onmouseout: partial(columnMouse, ' hidden')});","");$cd=apply_sql_function($X["fun"],$D);$oh=isset($l["privileges"]["order"])||$cd!=$D;echo($oh?"$cd":$cd);$Pe=($oh?"":'');if(!$X["fun"]&&isset($l["privileges"]["where"])){$Pe +.=' =';$Pe +.=script("qsl('a').onclick = partial(selectSearch, '".js_escape($y)."');");}echo($Pe?"":"");}$dd[$y]=$X["fun"];next($N);}}$we=array();if($_GET["modify"]){foreach($M +as$L){foreach($L +as$y=>$X)$we[$y]=max($we[$y],min(40,strlen(utf8_decode($X))));}}echo($Ba?"".lang(249):"")."
    ".checkbox("check[]",substr($xi,1),in_array(substr($xi,1),(array)$_POST["check"])).($be||information_schema(DB)?"":" ".lang(250).""));reset($N);foreach($L +as$y=>$X){if(isset($df[$y])){$c=current($N);$l=(array)$m[$y];if($X!=""&&(!isset($jc[$y])||$jc[$y]!=""))$jc[$y]=(is_mail($X)?$df[$y]:"");$_="";if(is_blob($l)&&$X!="")$_=ME.'download='.urlencode($a).'&field='.urlencode($y).$xi;if(!$_&&$X!==null){foreach((array)$Xc[$y]as$o){if(count($Xc[$y])==1||end($o["source"])==$y){$_="";foreach($o["source"]as$s=>$ph)$_ +.=where_link($s,$o["target"][$s],$M[$bf][$ph]);$_=($o["db"]!=""?preg_replace('~([?&]db=)[^&]+~','\1'.urlencode($o["db"]),ME):ME).'select='.urlencode($o["table"]).$_;if($o["ns"])$_=preg_replace('~([?&]ns=)[^&]+~','\1'.urlencode($o["ns"]),$_);if(count($o["source"])==1)break;}}}if($c=="COUNT(*)"){$_=ME."select=".urlencode($a);$s=0;foreach((array)$_GET["where"]as$W){if(!array_key_exists($W["col"],$wi))$_ +.=where_link($s++,$W["col"],$W["val"],$W["op"]);}foreach($wi +as$he=>$W)$_ +.=where_link($s++,$he,$W);}$yd=select_value($X,$_,$l,$Vh);$t=h("val[$xi][".bracket_escape($y)."]");$jg=idx(idx($_POST["val"],$xi),bracket_escape($y));$ec=!is_array($L[$y])&&is_utf8($yd)&&$M[$bf][$y]==$L[$y]&&!$dd[$y]&&!$l["generated"];$U=(preg_match('~^(AVG|MIN|MAX)\((.+)\)~',$c,$B)?$m[idf_unescape($B[2])]["type"]:$l["type"]);$Th=preg_match('~text|json|lob~',$U);$ce=preg_match(number_type(),$U)||preg_match('~^(CHAR_LENGTH|ROUND|FLOOR|CEIL|TIME_TO_SEC|COUNT|SUM)\(~',$c);echo"".($Th?"":"");}else{$Ae=strpos($yd,"");echo" data-text='".($Ae?2:($Th?1:0))."'".($ec?"":" data-warning='".h(lang(251))."'").">$yd";}}next($N);}if($Ba)echo"";adminer()->backwardKeysPrint($Ba,$M[$bf]);echo"
    \n","
    \n";}if(!is_ajax()){if($M||$F){$wc=true;if($_GET["page"]!="last"){if(!$z||(count($M)<$z&&($M||!$F)))$ad=($F?$F*$z:0)+count($M);elseif(JUSH!="sql"||!$be){$ad=($be?false:found_rows($S,$Z));if(intval($ad)$z||$F));if($Kf)echo(($ad===false?count($M)+1:$ad-$F*$z)>$z?'

    '.lang(252).''.script("qsl('a').onclick = partial(selectLoadMore, $z, '".lang(253)."…');",""):''),"\n";echo"

    \n";}if(adminer()->selectImportPrint())echo"

    ","".lang(73)."",script("qsl('a').onclick = partial(toggle, 'import');",""),"";echo +input_token(),"

    \n",(!$r&&$N?"":script("tableCheck();"));}}}if(is_ajax()){ob_end_clean();exit;}}elseif(isset($_GET["variables"])){$Q=isset($_GET["status"]);page_header($Q?lang(119):lang(118));$Pi=($Q?adminer()->showStatus():adminer()->showVariables());if(!$Pi)echo"

    ".lang(14)."\n";else{echo"\n";foreach($Pi +as$L){echo"";$y=array_shift($L);echo"
    ".h($y)."";foreach($L +as$X)echo"".nl_br(h($X));}echo"
    \n";}}elseif(isset($_GET["script"])){header("Content-Type: text/javascript; charset=utf-8");if($_GET["script"]=="db"){$Dh=array("Data_length"=>0,"Index_length"=>0,"Data_free"=>0);foreach(table_status()as$D=>$S){json_row("Comment-$D",h($S["Comment"]));if(!is_view($S)||preg_match('~materialized~i',$S["Engine"])){foreach(array("Engine","Collation")as$y)json_row("$y-$D",h($S[$y]));foreach($Dh+array("Auto_increment"=>0,"Rows"=>0)as$y=>$X){if($S[$y]!=""){$X=format_number($S[$y]);if($X>=0)json_row("$y-$D",($y=="Rows"&&$X&&$S["Engine"]==(JUSH=="pgsql"?"table":"InnoDB")?"~ $X":$X));if(isset($Dh[$y]))$Dh[$y]+=($S["Engine"]!="InnoDB"||$y!="Data_free"?$S[$y]:0);}elseif(array_key_exists($y,$S))json_row("$y-$D","?");}}}foreach($Dh +as$y=>$X)json_row("sum-$y",format_number($X));json_row("");}elseif($_GET["script"]=="kill")connection()->query("KILL ".number($_POST["kill"]));else{foreach(count_tables(adminer()->databases())as$j=>$X){json_row("tables-$j",$X);json_row("size-$j",db_size($j));}json_row("");}exit;}else{$Nh=array_merge((array)$_POST["tables"],(array)$_POST["views"]);if($Nh&&!$k&&!$_POST["search"]){$J=true;$C="";if(JUSH=="sql"&&$_POST["tables"]&&count($_POST["tables"])>1&&($_POST["drop"]||$_POST["truncate"]||$_POST["copy"]))queries("SET foreign_key_checks = 0");if($_POST["truncate"]){if($_POST["tables"])$J=truncate_tables($_POST["tables"]);$C=lang(257);}elseif($_POST["move"]){$J=move_tables((array)$_POST["tables"],(array)$_POST["views"],$_POST["target"]);$C=lang(258);}elseif($_POST["copy"]){$J=copy_tables((array)$_POST["tables"],(array)$_POST["views"],$_POST["target"]);$C=lang(259);}elseif($_POST["drop"]){if($_POST["views"])$J=drop_views($_POST["views"]);if($J&&$_POST["tables"])$J=drop_tables($_POST["tables"]);$C=lang(260);}elseif(JUSH=="sqlite"&&$_POST["check"]){foreach((array)$_POST["tables"]as$R){foreach(get_rows("PRAGMA integrity_check(".q($R).")")as$L)$C +.="".h($R).": ".h($L["integrity_check"])."
    ";}}elseif(JUSH!="sql"){$J=(JUSH=="sqlite"?queries("VACUUM"):apply_queries("VACUUM".($_POST["optimize"]?"":" ANALYZE"),$_POST["tables"]));$C=lang(261);}elseif(!$_POST["tables"])$C=lang(11);elseif($J=queries(($_POST["optimize"]?"OPTIMIZE":($_POST["check"]?"CHECK":($_POST["repair"]?"REPAIR":"ANALYZE")))." TABLE ".implode(", ",array_map('Adminer\idf_escape',$_POST["tables"])))){while($L=$J->fetch_assoc())$C +.="".h($L["Table"]).": ".h($L["Msg_text"])."
    ";}queries_redirect(substr(ME,0,-1),$C,$J);}page_header(($_GET["ns"]==""?lang(35).": ".h(DB):lang(77).": ".h($_GET["ns"])),$k,true);if(adminer()->homepage()){if($_GET["ns"]!==""){echo"

    ".lang(262)."

    \n";$Mh=tables_list();if(!$Mh)echo"

    ".lang(11)."\n";else{echo"

    \n";if(support("table")){echo"
    ".lang(263)."
    ",html_select("op",adminer()->operators(),idx($_POST,"op",JUSH=="elastic"?"should":"LIKE %%"))," ",script("qsl('input').onkeydown = partialArg(bodyKeydown, 'search');","")," \n","
    \n";if($_POST["search"]&&$_POST["query"]!=""){$_GET["where"][0]["op"]=$_POST["op"];search_tables();}}echo"
    \n","\n",script("mixin(qsl('table'), {onclick: tableClick, ondblclick: partialArg(tableClick, true)});"),'','\n";$T=0;foreach($Mh +as$D=>$U){$Si=($U!==null&&!preg_match('~table|sequence~i',$U));$t=h("Table-".$D);echo'
    '.script("qs('#check-all').onclick = partial(formCheck, /^(tables|views)\[/);",""),''.lang(134);$d=array("Engine"=>array(lang(264).''));if(collations())$d["Collation"]=array(lang(123).'');if(function_exists('Adminer\alter_table'))$d["Data_length"]=array(lang(265).doc_link(array('pgsql'=>'functions-admin.html#FUNCTIONS-ADMIN-DBOBJECT',)),"create",lang(42));if(support('indexes'))$d["Index_length"]=array(lang(266).doc_link(array('pgsql'=>'functions-admin.html#FUNCTIONS-ADMIN-DBOBJECT')),"indexes",lang(137));$d["Data_free"]=array(lang(267).'',"edit",lang(43));if(function_exists('Adminer\alter_table'))$d["Auto_increment"]=array(lang(49).'',"auto_increment=1&create",lang(42));$d["Rows"]=array(lang(268).doc_link(array('pgsql'=>'catalog-pg-class.html#CATALOG-PG-CLASS',)),"select",lang(39));if(support("comment"))$d["Comment"]=array(lang(48).doc_link(array('pgsql'=>'functions-info.html#FUNCTIONS-INFO-COMMENT-TABLE')));foreach($d +as$c)echo"$c[0]";echo"
    '.checkbox(($Si?"views[]":"tables[]"),$D,in_array("$D",$Nh,true),"","","",$t),''.(support("table")||support("indexes")?"".h($D).'':h($D));if($Si&&!preg_match('~materialized~i',$U)){$Zh=lang(133);echo''.(support("view")?"$Zh":$Zh),'?';}else{foreach($d +as$y=>$c){$t=" id='$y-".h($D)."'";echo($c[1]?"?":"");}$T++;}echo"\n";}echo"
    ".lang(241,count($Mh)),"".h(JUSH=="sql"?get_val("SELECT @@default_storage_engine"):""),"".h(db_collation(DB,collations()));foreach(array("Data_length","Index_length","Data_free")as$y)echo($d[$y]?"":"");echo"\n","
    \n",script("ajaxSetHtml('".js_escape(ME)."script=db');"),"
    \n";if(!information_schema(DB)){$Mi=" ".on_help("'VACUUM'");$uf=" ".on_help(JUSH=="sql"?"'OPTIMIZE TABLE'":"'VACUUM OPTIMIZE'");$og=(JUSH=="sqlite"?$Mi." ".on_help("'PRAGMA integrity_check'"):(JUSH=="pgsql"?$Mi.$uf:(JUSH=="sql"?" ".on_help("'ANALYZE TABLE'").$uf." ".on_help("'CHECK TABLE'")." ".on_help("'REPAIR TABLE'"):""))).(function_exists('Adminer\truncate_tables')?" ".on_help(JUSH=="sqlite"?"'DELETE'":"'TRUNCATE".(JUSH=="pgsql"?"'":" TABLE'")).confirm():"").(function_exists('Adminer\drop_tables')?"".on_help("'DROP TABLE'").confirm():"");echo($og?"\n";}echo"
    \n",script("tableCheck();");}echo(function_exists('Adminer\alter_table')?"

    ".lang(70)."

    \n";$Og=routines();if($Og){echo"\n",'\n";foreach($Og +as$L){$D=($L["SPECIFIC_NAME"]==$L["ROUTINE_NAME"]?"":"&name=".urlencode($L["ROUTINE_NAME"]));echo'','
    '.lang(188).''.lang(47).''.lang(218)."
    '.h($L["ROUTINE_NAME"]).'',''.h($L["ROUTINE_TYPE"]),''.h($L["DTD_IDENTIFIER"]),''.lang(140)."";}echo"
    \n";}echo'

    ".lang(71)."

    \n";$eh=get_vals("SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = current_schema() ORDER BY sequence_name");if($eh){echo"\n","\n";foreach($eh +as$X)echo"
    ".lang(188)."
    ".h($X)."\n";echo"
    \n";}echo"

    ".lang(6)."

    \n";$Ji=types();if($Ji){echo"\n","\n";foreach($Ji +as$X)echo"
    ".lang(188)."
    ".h($X)."\n";echo"
    \n";}echo"

    Błąd pluginu PostgreSQL

    '; + echo '

    ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

    '; + echo '

    Sprawdź konfigurację i uprawnienia pluginu.

    '; + echo ''; + exit; +} + +function autoload_plugin_libs(): void +{ + $files = [ + PLUGIN_EXEC_DIR . '/lib/Http.php', + PLUGIN_EXEC_DIR . '/lib/Settings.php', + PLUGIN_EXEC_DIR . '/lib/AdminerRuntimeConfig.php', + PLUGIN_EXEC_DIR . '/lib/DirectAdminUser.php', + PLUGIN_EXEC_DIR . '/lib/Lang.php', + PLUGIN_EXEC_DIR . '/lib/LocalizedException.php', + PLUGIN_EXEC_DIR . '/lib/CsrfGuard.php', + PLUGIN_EXEC_DIR . '/lib/NamePolicy.php', + PLUGIN_EXEC_DIR . '/lib/PostgresService.php', + PLUGIN_EXEC_DIR . '/lib/AdminerSso.php', + PLUGIN_EXEC_DIR . '/lib/BackupQueueService.php', + PLUGIN_EXEC_DIR . '/lib/FilePicker.php', + PLUGIN_EXEC_DIR . '/lib/AppContext.php', + ]; + + foreach ($files as $file) { + require_once $file; + } +} + +function render_access_denied_message(Lang $lang, string $skin, string $langCode): void +{ + $path = PLUGIN_ROOT . '/user/' . strtolower(trim($skin)) . '.css'; + if (!is_file($path)) { + $path = PLUGIN_ROOT . '/user/enhanced.css'; + } + $css = is_file($path) ? (string)file_get_contents($path) : ''; + + $message = $lang->t('Bazy danych PostgreSQL nie są dostępne dla Twojego konta - skontaktuj się z pomocą techniczną aby uzyskać pomoc i więcej informacji'); + $title = $lang->t('Błąd'); + + header('Content-Type: text/html; charset=UTF-8'); + echo ''; + echo ''; + echo '' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + echo ''; + echo ''; + echo '
    '; + echo '
    '; + echo '
    ' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
    '; + echo '
    '; + echo '
    '; + echo ''; +} diff --git a/exec/handlers/assign_database.php b/exec/handlers/assign_database.php new file mode 100644 index 0000000..37b2b3e --- /dev/null +++ b/exec/handlers/assign_database.php @@ -0,0 +1,88 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $dbNames = array_map(static fn(array $db): string => $db['name'], $ctx->pg->listDatabasesForUser($daUser)); + $roleNames = array_map(static fn(array $r): string => $r['name'], $ctx->pg->listRolesForUser($daUser)); + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('assign_database_submit'); + + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + $roleName = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + + if (!in_array($dbName, $dbNames, true)) { + throw new RuntimeException('Baza nie należy do bieżącego użytkownika DA: ' . $dbName); + } + if (!in_array($roleName, $roleNames, true)) { + throw new RuntimeException('Użytkownik PostgreSQL nie należy do bieżącego konta DA: ' . $roleName); + } + + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + + $ctx->pg->grantPrivileges($dbName, $roleName, $privileges); + + $hostEntries = $ctx->pg->getRoleHostEntries($daUser, $roleName); + $hasLocalhost = false; + foreach ($hostEntries as $entry) { + if ($entry['host'] === 'localhost') { + $hasLocalhost = true; + break; + } + } + + if (!$hasLocalhost) { + $hostEntries[] = ['host' => 'localhost', 'note' => '']; + $ctx->pg->replaceRoleHostsWithNotes($daUser, $roleName, $hostEntries); + } + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Przypisanie wykonane, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + $ok[] = 'Przypisano bazę ' . $dbName . ' do użytkownika ' . $roleName . '.'; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + $dbOptions = ''; + foreach ($dbNames as $dbName) { + $dbOptions .= ''; + } + + $roleOptions = ''; + foreach ($roleNames as $roleName) { + $roleOptions .= ''; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = ($code === 'ALL') ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('assign_database_submit'); + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '

    Uprawnienia

    ' . $privCheckboxes . '
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Przypisywanie Bazy do Użytkownika', $body, $ok, $errors); +}; diff --git a/exec/handlers/change_password.php b/exec/handlers/change_password.php new file mode 100644 index 0000000..a53e42a --- /dev/null +++ b/exec/handlers/change_password.php @@ -0,0 +1,586 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + $showRemoteHosts = $ctx->settings->allowRemoteHosts(); + $postgresPort = $ctx->pg->serverPort(); + $passwordChangedFor = ''; + $changedPassword = ''; + $createdRoleName = ''; + $createdRolePassword = ''; + $createdRoleAssignedDbs = []; + $showCreateUserForm = (strtolower(trim($ctx->queryString('add_user'))) === '1'); + $deletePromptRoleRaw = trim($ctx->postString('delete_role')); + if ($deletePromptRoleRaw === '') { + $deletePromptRoleRaw = trim($ctx->queryString('delete_role')); + } + $deletePromptRole = ''; + $deletePromptAssignedDbs = []; + + $createRoleSuffixInput = trim($ctx->postString('create_role_name')); + if (strpos($createRoleSuffixInput, $prefix) === 0) { + $createRoleSuffixInput = substr($createRoleSuffixInput, strlen($prefix)); + } + $createPasswordInput = $ctx->postString('create_password'); + $createSelectedDbsRaw = $ctx->postArray('create_user_databases'); + $createSelectedDbs = array_values(array_unique(array_filter($createSelectedDbsRaw, static fn(string $db): bool => $db !== ''))); + + $databases = $ctx->pg->listDatabasesForUser($daUser); + $allDbs = array_map(static fn(array $db): string => $db['name'], $databases); + $roles = $ctx->pg->listRolesForUser($daUser); + $roleNames = array_map(static fn(array $r): string => $r['name'], $roles); + $preferredRoleRaw = $ctx->postString('role_name'); + if ($preferredRoleRaw === '') { + $preferredRoleRaw = $ctx->queryString('role'); + } + + if ($ctx->isPost()) { + $postAction = $ctx->postString('form_action'); + try { + if ($postAction === 'create_user_inline') { + $ctx->requireCsrf('create_user_inline_submit'); + $showCreateUserForm = true; + + $newRole = NamePolicy::normalizeRoleName($ctx->postString('create_role_name'), $prefix); + if ($ctx->pg->roleExists($newRole)) { + throw new RuntimeException('Użytkownik PostgreSQL już istnieje: ' . $newRole); + } + + $newPassword = $ctx->postString('create_password'); + if (strlen($newPassword) < 12) { + throw new RuntimeException('Hasło użytkownika musi mieć co najmniej 12 znaków.'); + } + + $selectedDbs = []; + foreach ($createSelectedDbs as $rawDbName) { + $dbName = NamePolicy::normalizeDatabaseName($rawDbName, $prefix); + if (!in_array($dbName, $allDbs, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $dbName); + } + if (!in_array($dbName, $selectedDbs, true)) { + $selectedDbs[] = $dbName; + } + } + + $ctx->pg->createRole($newRole, $newPassword); + try { + $ctx->pg->replaceRoleHostsWithNotes($daUser, $newRole, [['host' => 'localhost', 'note' => '']]); + + foreach ($selectedDbs as $dbName) { + $ctx->pg->grantPrivileges($dbName, $newRole, NamePolicy::defaultPrivileges()); + } + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Użytkownik został utworzony, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + $createdRoleName = $newRole; + $createdRolePassword = $newPassword; + $createdRoleAssignedDbs = $selectedDbs; + + if (empty($selectedDbs)) { + $ok[] = 'Utworzono użytkownika ' . $newRole . ' bez przypisanych baz danych.'; + } else { + $ok[] = 'Utworzono użytkownika ' . $newRole . ' i przypisano do ' . count($selectedDbs) . ' baz(y).'; + } + + $preferredRoleRaw = $newRole; + $showCreateUserForm = false; + $createRoleSuffixInput = ''; + $createPasswordInput = ''; + $createSelectedDbs = []; + } catch (Throwable $inner) { + try { + $ctx->pg->deleteRoleHosts($daUser, $newRole); + } catch (Throwable $ignored) { + } + try { + $ctx->pg->dropRole($newRole); + } catch (Throwable $ignored) { + } + throw $inner; + } + } elseif ($postAction === 'delete_user_confirm') { + $ctx->requireCsrf('delete_user_submit'); + + $roleToDelete = NamePolicy::normalizeRoleName($ctx->postString('delete_role'), $prefix); + if (!in_array($roleToDelete, $roleNames, true)) { + throw new RuntimeException('Użytkownik PostgreSQL nie należy do konta DA: ' . $roleToDelete); + } + + $owned = array_values(array_unique($ctx->pg->roleOwnedDatabases($roleToDelete, $daUser))); + if (!empty($owned)) { + throw new RuntimeException( + 'Nie można usunąć użytkownika, ponieważ jest właścicielem baz: ' . implode(', ', $owned) + ); + } + + $assigned = array_values(array_unique($ctx->pg->roleAssignedDatabases($roleToDelete, $daUser))); + foreach ($assigned as $dbName) { + $owner = $ctx->pg->getDatabaseOwner($dbName); + if ($owner !== null && $owner !== $roleToDelete) { + $ctx->pg->reassignOwnedObjects($dbName, $roleToDelete, $owner); + } + $ctx->pg->revokePrivileges($dbName, $roleToDelete); + } + + $ctx->pg->deleteRoleHosts($daUser, $roleToDelete); + $ctx->pg->dropRole($roleToDelete); + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Usuwanie wykonane, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + $ok[] = 'Usunięto użytkownika ' . $roleToDelete . '.'; + if ($preferredRoleRaw === $roleToDelete) { + $preferredRoleRaw = ''; + } + $deletePromptRoleRaw = ''; + } else { + $ctx->requireCsrf('user_manage_submit'); + if (empty($roleNames)) { + throw new RuntimeException('Brak użytkowników PostgreSQL do zarządzania.'); + } + + $selectedRole = NamePolicy::normalizeRoleName($ctx->postString('role_name', $preferredRoleRaw), $prefix); + if (!in_array($selectedRole, $roleNames, true)) { + throw new RuntimeException('Użytkownik PostgreSQL nie należy do konta DA: ' . $selectedRole); + } + $preferredRoleRaw = $selectedRole; + + if ($postAction === 'change_password') { + $password = $ctx->postString('password'); + if (strlen($password) < 12) { + throw new RuntimeException('Nowe hasło musi mieć minimum 12 znaków.'); + } + $ctx->pg->changeRolePassword($selectedRole, $password); + $passwordChangedFor = $selectedRole; + $changedPassword = $password; + } elseif ($postAction === 'grant_db') { + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + if (!in_array($dbName, $allDbs, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $dbName); + } + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + $ctx->pg->grantPrivileges($dbName, $selectedRole, $privileges); + $ok[] = 'Przyznano dostęp do bazy ' . $dbName . ' użytkownikowi ' . $selectedRole . '.'; + } elseif ($postAction === 'revoke_db') { + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + $ctx->pg->revokePrivileges($dbName, $selectedRole); + $ctx->pg->deleteRoleHosts($daUser, $selectedRole, $dbName); + $ok[] = 'Odebrano dostęp do bazy ' . $dbName . ' użytkownikowi ' . $selectedRole . '.'; + } elseif ($postAction === 'add_host' && $showRemoteHosts) { + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + if (!in_array($dbName, $allDbs, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $dbName); + } + if (!in_array($selectedRole, $ctx->pg->listDatabaseUsers($daUser, $dbName), true)) { + throw new RuntimeException('Użytkownik PostgreSQL nie ma dostępu do wybranej bazy: ' . $selectedRole); + } + + $allowAllHosts = $ctx->postString('allow_all_hosts') === '1'; + $host = $allowAllHosts + ? NamePolicy::ALL_IPV4_HOST_PATTERN + : NamePolicy::normalizeRemoteIpv4AccessPattern($ctx->postString('host')); + $note = NamePolicy::normalizeHostComment($ctx->postString('host_note')); + if ($allowAllHosts && $note === '') { + $note = 'Dostęp z każdego adresu IPv4'; + } + + $entries = $ctx->pg->getRoleHostEntries($daUser, $selectedRole, $dbName); + $map = []; + foreach ($entries as $entry) { + $map[$entry['host']] = $entry['note']; + } + $map[$host] = $note; + if (count($map) > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla wybranej bazy i użytkownika PostgreSQL.'); + } + $save = []; + foreach ($map as $h => $n) { + $save[] = ['host' => $h, 'note' => $n]; + } + $ctx->pg->replaceRoleHostsWithNotes($daUser, $selectedRole, $save, $dbName); + $restartPostgresql = $ctx->postString('restart_postgresql') === '1'; + $sync = $ctx->pg->syncHbaRules($restartPostgresql); + if (!$sync['ok']) { + $errors[] = 'Host dodany, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } elseif ($restartPostgresql) { + $ok[] = 'Synchronizacja konfiguracji PostgreSQL zakończona i usługa PostgreSQL została zrestartowana.'; + } + $ok[] = 'Dodano host ' . $host . ' dla użytkownika ' . $selectedRole . ' i bazy ' . $dbName . '.'; + } elseif ($postAction === 'remove_host' && $showRemoteHosts) { + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + if (!in_array($dbName, $allDbs, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $dbName); + } + $host = trim($ctx->postString('host')); + $entries = $ctx->pg->getRoleHostEntries($daUser, $selectedRole, $dbName); + $save = []; + foreach ($entries as $entry) { + if ($entry['host'] === $host) { + continue; + } + $save[] = $entry; + } + $ctx->pg->replaceRoleHostsWithNotes($daUser, $selectedRole, $save, $dbName); + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Host usunięty, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + $ok[] = 'Usunięto host ' . $host . ' dla użytkownika ' . $selectedRole . ' i bazy ' . $dbName . '.'; + } + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + if ($postAction === 'create_user_inline') { + $showCreateUserForm = true; + } + if ($postAction === 'delete_user_confirm') { + $deletePromptRoleRaw = trim($ctx->postString('delete_role')); + } + } + } + + $databases = $ctx->pg->listDatabasesForUser($daUser); + $allDbs = array_map(static fn(array $db): string => $db['name'], $databases); + $roles = $ctx->pg->listRolesForUser($daUser); + $roleNames = array_map(static fn(array $r): string => $r['name'], $roles); + $selectedRole = ''; + if (!empty($roleNames)) { + $candidateRaw = $preferredRoleRaw !== '' ? $preferredRoleRaw : $roleNames[0]; + try { + $candidate = NamePolicy::normalizeRoleName($candidateRaw, $prefix); + } catch (Throwable $e) { + $candidate = $roleNames[0]; + } + if (!in_array($candidate, $roleNames, true)) { + $candidate = $roleNames[0]; + } + $selectedRole = $candidate; + } + + if ($deletePromptRoleRaw !== '') { + try { + $deletePromptRole = NamePolicy::normalizeRoleName($deletePromptRoleRaw, $prefix); + if (!in_array($deletePromptRole, $roleNames, true)) { + throw new RuntimeException('Użytkownik PostgreSQL nie należy do konta DA: ' . $deletePromptRole); + } + $deletePromptAssignedDbs = array_values(array_unique($ctx->pg->roleAssignedDatabases($deletePromptRole, $daUser))); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $deletePromptRole = ''; + $deletePromptAssignedDbs = []; + } + } + + $createSelectedDbSet = []; + foreach ($createSelectedDbs as $rawDbName) { + try { + $dbName = NamePolicy::normalizeDatabaseName($rawDbName, $prefix); + } catch (Throwable $e) { + continue; + } + if (in_array($dbName, $allDbs, true)) { + $createSelectedDbSet[$dbName] = true; + } + } + + $roleOptions = ''; + foreach ($roleNames as $roleName) { + $sel = ($roleName === $selectedRole) ? ' selected' : ''; + $roleOptions .= ''; + } + + $assignedDbs = []; + $grantableDbs = $allDbs; + if ($selectedRole !== '') { + $assignedDbs = $ctx->pg->roleAssignedDatabases($selectedRole, $daUser); + $grantableDbs = array_values(array_diff($allDbs, $assignedDbs)); + } + + $dbOptions = ''; + foreach ($grantableDbs as $dbName) { + $dbOptions .= ''; + } + + $assignedDbOptions = ''; + foreach ($assignedDbs as $dbName) { + $assignedDbOptions .= ''; + } + + $dbRows = ''; + foreach ($assignedDbs as $dbName) { + $profile = $ctx->pg->getGrantProfile($dbName, $selectedRole); + $label = (empty($profile) || in_array('ALL', $profile, true)) + ? 'Pełny dostęp' + : implode(', ', $profile); + + $dbRows .= ''; + $dbRows .= '' . AppContext::e($dbName) . ''; + $dbRows .= '' . AppContext::e($label) . ''; + $dbRows .= ''; + $dbRows .= 'Zarządzaj'; + $dbRows .= 'Przywileje'; + $dbRows .= '
    '; + $dbRows .= $ctx->csrfField('user_manage_submit'); + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= '
    '; + $dbRows .= ''; + $dbRows .= ''; + } + if ($dbRows === '') { + $dbRows = 'Brak przypisanych baz danych.'; + } + + $hostsRows = ''; + $hostsCount = 0; + $hasAllHostsEntry = false; + if ($showRemoteHosts && $selectedRole !== '') { + foreach ($assignedDbs as $dbName) { + $hostEntries = $ctx->pg->getRoleHostEntries($daUser, $selectedRole, $dbName); + $hostsCount += count($hostEntries); + foreach ($hostEntries as $entry) { + $host = $entry['host']; + $note = $entry['note']; + if ($host === NamePolicy::ALL_IPV4_HOST_PATTERN) { + $hasAllHostsEntry = true; + } + $hostsRows .= ''; + $hostsRows .= '' . AppContext::e($dbName) . ''; + $hostsRows .= '' . AppContext::e($host) . ''; + $hostsRows .= '' . AppContext::e($note) . ''; + $hostsRows .= ''; + $hostsRows .= '
    '; + $hostsRows .= $ctx->csrfField('user_manage_submit'); + $hostsRows .= ''; + $hostsRows .= ''; + $hostsRows .= ''; + $hostsRows .= ''; + $hostsRows .= ''; + $hostsRows .= '
    '; + $hostsRows .= ''; + $hostsRows .= ''; + } + } + if ($hostsRows === '') { + $hostsRows = 'Brak dodatkowych hostów dla przypisanych baz.'; + } + } + + $endpoint = $ctx->pg->connectionEndpoint(); + $endpointHost = trim($endpoint['host']) !== '' ? $endpoint['host'] : 'localhost'; + $endpointPort = $endpoint['port'] > 0 ? (string)$endpoint['port'] : '5432'; + $createdUserCard = ''; + if ($createdRoleName !== '' && $createdRolePassword !== '') { + $assignedDbsHtml = ''; + if (!empty($createdRoleAssignedDbs)) { + $assignedItems = ''; + foreach ($createdRoleAssignedDbs as $assignedDbName) { + $assignedItems .= '
  • ' . AppContext::e($assignedDbName) . '
  • '; + } + $assignedDbsHtml .= '
    Przypisane bazy danych:
    '; + $assignedDbsHtml .= '
      ' . $assignedItems . '
    '; + } + + $createdUserCard .= '
    '; + $createdUserCard .= '
    '; + $createdUserCard .= '
    '; + $createdUserCard .= '

    Dane dostępowe użytkownika PostgreSQL

    '; + $createdUserCard .= '
    '; + $createdUserCard .= '
    Nazwa hosta:
    ' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')
    '; + $createdUserCard .= '
    Nazwa użytkownika:
    ' . AppContext::e($createdRoleName) . '
    '; + $createdUserCard .= '
    Hasło:
    ' . AppContext::e($createdRolePassword) . '
    '; + $createdUserCard .= $assignedDbsHtml; + $createdUserCard .= '
    '; + } + + $passwordChangedCard = ''; + if ($passwordChangedFor !== '' && $changedPassword !== '') { + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '

    Hasło zostało zmienione

    '; + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '
    Nazwa hosta:
    ' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')
    '; + $passwordChangedCard .= '
    Nazwa użytkownika:
    ' . AppContext::e($passwordChangedFor) . '
    '; + $passwordChangedCard .= '
    Hasło:
    ' . AppContext::e($changedPassword) . '
    '; + $passwordChangedCard .= '
    '; + } + + $body = ''; + $body .= $createdUserCard; + $body .= $passwordChangedCard; + $body .= '
    '; + $body .= '

    Zarządzaj użytkownikami

    '; + $body .= '
    '; + $body .= '
    '; + $body .= 'Dodaj użytkownika'; + if ($showCreateUserForm) { + $body .= 'Anuluj'; + } + $body .= '
    '; + if ($selectedRole !== '') { + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= 'Usuń użytkownika'; + $body .= '
    '; + } else { + $body .= 'Brak użytkowników PostgreSQL. Dodaj pierwszego użytkownika.'; + } + $body .= '
    '; + $body .= '

    Szczegółowe informacje o użytkowniku.

    '; + if ($selectedRole !== '') { + $body .= ''; + $body .= ''; + if ($showRemoteHosts) { + $body .= ''; + } else { + $body .= ''; + } + $body .= '
    Nazwa użytkownika
    ' . AppContext::e($selectedRole) . '
    Bazy danych
    ' . AppContext::e((string)count($assignedDbs)) . '
    Dozwolone hosty
    ' . AppContext::e((string)$hostsCount) . '
    Dozwolone hosty
    localhost
    '; + } else { + $body .= '

    Aby zarządzać użytkownikami najpierw utwórz użytkownika PostgreSQL.

    '; + } + $body .= '
    '; + + if ($deletePromptRole !== '') { + $cancelUrl = $ctx->url('change_password.html', $selectedRole !== '' ? ['role' => $selectedRole] : []); + $body .= '
    '; + $body .= '
    Czy na pewno chcesz usunąć użytkownika ' . AppContext::e($deletePromptRole) . '?
    '; + + if (count($deletePromptAssignedDbs) === 1) { + $body .= '
    Uwaga użytkownik ' . AppContext::e($deletePromptRole) . ' jest przypisany do następującej bazy danych ' . AppContext::e($deletePromptAssignedDbs[0]) . '.
    '; + } elseif (count($deletePromptAssignedDbs) > 1) { + $body .= '
    Uwaga użytkownik ' . AppContext::e($deletePromptRole) . ' jest przypisany do następujących baz danych:
    '; + $body .= '
      '; + foreach ($deletePromptAssignedDbs as $assignedDbName) { + $body .= '
    • ' . AppContext::e($assignedDbName) . '
    • '; + } + $body .= '
    '; + } + + $body .= '
    '; + $body .= $ctx->csrfField('delete_user_submit'); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + + if ($showCreateUserForm) { + $body .= '
    '; + $body .= '

    Utwórz nowego użytkownika PostgreSQL

    '; + $body .= '
    '; + $body .= $ctx->csrfField('create_user_inline_submit'); + $body .= ''; + $body .= '
    '; + $body .= '
    ' . AppContext::e($prefix) . '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= 'Przypisz do baz danych (opcjonalnie)'; + if (empty($allDbs)) { + $body .= '

    Brak baz do przypisania.

    '; + } else { + $body .= '
    '; + foreach ($allDbs as $dbName) { + $checked = isset($createSelectedDbSet[$dbName]) ? ' checked' : ''; + $body .= ''; + } + $body .= '
    '; + } + $body .= '

    Możesz pozostawić użytkownika bez przypisania do baz i nadać dostęp później.

    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + + if ($selectedRole !== '') { + $body .= '
    '; + $body .= '

    Zarządzanie hasłami

    '; + $body .= '
    '; + $body .= $ctx->csrfField('user_manage_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '

    Dostęp do baz danych

    '; + $body .= '' . $dbRows . '
    Nazwa bazy danychPrzywilejeAkcje
    '; + $body .= '
    '; + $body .= $ctx->csrfField('user_manage_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + if ($showRemoteHosts) { + $body .= '
    '; + $body .= '

    Dozwolone hosty

    '; + if ($hasAllHostsEntry) { + $body .= '
    Dla co najmniej jednej bazy włączono dostęp z każdego adresu IPv4. Plugin nie otwiera portu PostgreSQL w CSF dla tego trybu. Port PostgreSQL do otwarcia w CSF: ' . AppContext::e((string)$postgresPort) . '.
    '; + } + $body .= '

    Lista źródłowych adresów IP/CIDR, które mogą łączyć się z konkretną bazą przy użyciu poświadczeń użytkownika.

    '; + $body .= '' . $hostsRows . '
    BazaHost/CIDRKomentarzAkcje
    '; + $body .= '
    '; + $body .= $ctx->csrfField('user_manage_submit'); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '

    Ten checkbox ignoruje pole IPv4/CIDR i wymaga ręcznego otwarcia portu PostgreSQL w CSF. Port PostgreSQL do otwarcia w CSF: ' . AppContext::e((string)$postgresPort) . '.

    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + } + + $ctx->renderPage('Zarządzaj użytkownikami', $body, $ok, $errors); +}; diff --git a/exec/handlers/create_database.php b/exec/handlers/create_database.php new file mode 100644 index 0000000..ca413c6 --- /dev/null +++ b/exec/handlers/create_database.php @@ -0,0 +1,19 @@ +isPost()) { + if ($ctx->postString('form_action') === '') { + $_POST['form_action'] = 'create_database'; + } + if ($ctx->postString('db_suffix') === '' && $ctx->postString('db_name') !== '') { + $_POST['db_suffix'] = $ctx->postString('db_name'); + } + + $handler = require __DIR__ . '/index.php'; + $handler($ctx); + return; + } + + Http::redirect($ctx->url('index.html')); +}; diff --git a/exec/handlers/create_user.php b/exec/handlers/create_user.php new file mode 100644 index 0000000..c9e6369 --- /dev/null +++ b/exec/handlers/create_user.php @@ -0,0 +1,137 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + $showRemoteHosts = $ctx->settings->allowRemoteHosts(); + + $databases = $ctx->pg->listDatabasesForUser($daUser); + $dbNames = array_map(static fn(array $db): string => $db['name'], $databases); + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('create_user_submit'); + + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if ($ctx->pg->roleExists($role)) { + throw new RuntimeException('Użytkownik PostgreSQL już istnieje: ' . $role); + } + + $password = $ctx->postString('password'); + if (strlen($password) < 12) { + throw new RuntimeException('Hasło użytkownika musi mieć co najmniej 12 znaków.'); + } + + $selectedDbsRaw = $ctx->postArray('databases'); + $selectedDbs = []; + foreach ($selectedDbsRaw as $db) { + $normalizedDb = NamePolicy::normalizeDatabaseName($db, $prefix); + if (!in_array($normalizedDb, $dbNames, true)) { + throw new RuntimeException('Nieprawidłowa baza do przypisania: ' . $normalizedDb); + } + $selectedDbs[] = $normalizedDb; + } + $selectedDbs = array_values(array_unique($selectedDbs)); + + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + + $globalHostEntries = [['host' => 'localhost', 'note' => '']]; + $remoteHostEntry = null; + if ($showRemoteHosts) { + $remoteHostRaw = trim($ctx->postString('remote_host')); + if ($remoteHostRaw !== '') { + $remoteHost = NamePolicy::normalizeRemoteIpv4AccessPattern($remoteHostRaw); + $remoteComment = NamePolicy::normalizeHostComment($ctx->postString('remote_comment')); + $remoteHostEntry = ['host' => $remoteHost, 'note' => $remoteComment]; + } + } + if ($remoteHostEntry !== null && empty($selectedDbs)) { + throw new RuntimeException('Host zdalny można dodać tylko dla wybranej bazy.'); + } + + $ctx->pg->createRole($role, $password); + try { + $ctx->pg->replaceRoleHostsWithNotes($daUser, $role, $globalHostEntries); + + foreach ($selectedDbs as $dbName) { + $ctx->pg->grantPrivileges($dbName, $role, $privileges); + if ($remoteHostEntry !== null) { + $ctx->pg->replaceRoleHostsWithNotes($daUser, $role, [$remoteHostEntry], $dbName); + } + } + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Użytkownik został utworzony, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + $ok[] = 'Utworzono użytkownika ' . $role . ' i przypisano ' . count($selectedDbs) . ' baz(y).'; + } catch (Throwable $inner) { + try { + foreach ($selectedDbs as $dbName) { + $ctx->pg->revokePrivileges($dbName, $role); + } + $ctx->pg->deleteRoleHosts($daUser, $role); + $ctx->pg->dropRole($role); + } catch (Throwable $ignored) { + } + throw $inner; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + $dbCheckboxes = ''; + foreach ($dbNames as $dbName) { + $dbCheckboxes .= ''; + } + if ($dbCheckboxes === '') { + $dbCheckboxes = '

    Brak baz do przypisania.

    '; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = ($code === 'ALL') ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $remoteFields = ''; + if ($showRemoteHosts) { + $remoteFields .= '
    '; + $remoteFields .= '
    '; + $remoteFields .= '
    '; + $remoteFields .= '
    '; + } else { + $remoteFields .= '

    Hosty zdalne są wyłączone w konfiguracji pluginu (`allow_remote_hosts=0`).

    '; + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('create_user_submit'); + + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '

    `localhost` jest dodawany automatycznie.

    '; + $body .= '
    ' . $dbCheckboxes . '
    '; + $body .= '
    '; + + $body .= $remoteFields; + + $body .= '

    Uprawnienia dla przypisywanych baz

    ' . $privCheckboxes . '
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Tworzenie Użytkownika PostgreSQL', $body, $ok, $errors); +}; diff --git a/exec/handlers/delete_databases.php b/exec/handlers/delete_databases.php new file mode 100644 index 0000000..116c922 --- /dev/null +++ b/exec/handlers/delete_databases.php @@ -0,0 +1,171 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $availableDbs = array_map(static fn(array $db): string => $db['name'], $ctx->pg->listDatabasesForUser($daUser)); + + $selectedDatabases = $ctx->postArray('databases'); + if (empty($selectedDatabases)) { + $queryDb = $ctx->queryString('db'); + if ($queryDb !== '') { + $selectedDatabases = [$queryDb]; + } + } + + $selectedDatabases = array_values(array_unique(array_filter($selectedDatabases, static fn(string $v): bool => $v !== ''))); + + $confirmDelete = $ctx->postString('confirm_delete') === '1'; + + if ($ctx->isPost() && !$confirmDelete) { + try { + $ctx->requireCsrf('select_delete_databases'); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $selectedDatabases = []; + } + } + + if ($ctx->isPost() && $confirmDelete) { + try { + $ctx->requireCsrf('delete_databases_confirm'); + + if (empty($selectedDatabases)) { + throw new RuntimeException('Nie wybrano baz danych do usunięcia.'); + } + + $deleteRelatedUsers = $ctx->postString('delete_related_users') === '1'; + + $deletedDbs = []; + $deletedUsers = []; + $keptUsers = []; + + foreach ($selectedDatabases as $rawDb) { + try { + $dbName = NamePolicy::normalizeDatabaseName($rawDb, $prefix); + if (!in_array($dbName, $availableDbs, true)) { + $errors[] = 'Pominięto bazę spoza konta: ' . $dbName; + continue; + } + + $users = $ctx->pg->listDatabaseUsers($daUser, $dbName); + $users = array_values(array_unique($users)); + + foreach ($users as $roleName) { + $ctx->pg->revokePrivileges($dbName, $roleName); + $ctx->pg->deleteRoleHosts($daUser, $roleName, $dbName); + } + + $ctx->pg->dropDatabase($dbName); + $deletedDbs[] = $dbName; + + if ($deleteRelatedUsers) { + foreach ($users as $roleName) { + $assigned = $ctx->pg->roleAssignedDatabases($roleName, $daUser); + $owned = $ctx->pg->roleOwnedDatabases($roleName, $daUser); + + if (empty($assigned) && empty($owned)) { + try { + $ctx->pg->deleteRoleHosts($daUser, $roleName); + $ctx->pg->dropRole($roleName); + $deletedUsers[] = $roleName; + } catch (Throwable $dropErr) { + $keptUsers[] = $roleName . ' (' . $dropErr->getMessage() . ')'; + } + } else { + $keptUsers[] = $roleName . ' (ma nadal przypisane bazy)'; + } + } + } + } catch (Throwable $inner) { + $errors[] = $rawDb . ': ' . $inner->getMessage(); + } + } + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Operacja wykonana, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + if (!empty($deletedDbs)) { + $ok[] = 'Usunięto bazy: ' . implode(', ', array_values(array_unique($deletedDbs))); + } + if (!empty($deletedUsers)) { + $ok[] = 'Usunięto użytkowników powiązanych z usuniętymi bazami: ' . implode(', ', array_values(array_unique($deletedUsers))); + } + if (!empty($keptUsers)) { + $errors[] = 'Użytkownicy pozostawieni: ' . implode('; ', array_values(array_unique($keptUsers))); + } + + $selectedDatabases = []; + $availableDbs = array_map(static fn(array $db): string => $db['name'], $ctx->pg->listDatabasesForUser($daUser)); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if (empty($selectedDatabases)) { + $rows = ''; + foreach ($availableDbs as $dbName) { + $users = $ctx->pg->listDatabaseUsers($daUser, $dbName); + $rows .= ''; + $rows .= ''; + $rows .= '' . AppContext::e($dbName) . ''; + $rows .= '' . AppContext::e(implode(', ', $users)) . ''; + $rows .= ''; + } + if ($rows === '') { + $rows = 'Brak baz do usunięcia.'; + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('select_delete_databases'); + $body .= '' . $rows . '
    BazaPrzypisani użytkownicy
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Usuwanie Baz Danych', $body, $ok, $errors); + return; + } + + $confirmRows = ''; + foreach ($selectedDatabases as $rawDb) { + try { + $dbName = NamePolicy::normalizeDatabaseName($rawDb, $prefix); + $users = $ctx->pg->listDatabaseUsers($daUser, $dbName); + + $confirmRows .= ''; + $confirmRows .= '' . AppContext::e($dbName) . ''; + $confirmRows .= '' . AppContext::e(implode(', ', $users)) . ''; + $confirmRows .= ''; + } catch (Throwable $e) { + $errors[] = $rawDb . ': ' . $e->getMessage(); + } + } + + if ($confirmRows === '') { + $ctx->renderPage('Usuwanie Baz Danych', '

    Brak poprawnych baz do usunięcia.

    ', $ok, $errors); + return; + } + + $body = ''; + $body .= '

    Potwierdź usunięcie baz danych. Operacja jest nieodwracalna.

    '; + $body .= '
    '; + $body .= $ctx->csrfField('delete_databases_confirm'); + $body .= ''; + $body .= '' . $confirmRows . '
    BazaPrzypisani użytkownicy
    '; + $body .= '

    '; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Potwierdzenie Usuwania Baz Danych', $body, $ok, $errors); +}; diff --git a/exec/handlers/delete_users.php b/exec/handlers/delete_users.php new file mode 100644 index 0000000..ae7c678 --- /dev/null +++ b/exec/handlers/delete_users.php @@ -0,0 +1,158 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $availableRoles = array_map(static fn(array $r): string => $r['name'], $ctx->pg->listRolesForUser($daUser)); + + $selectedRoles = $ctx->postArray('roles'); + if (empty($selectedRoles)) { + $queryRole = $ctx->queryString('role'); + if ($queryRole !== '') { + $selectedRoles = [$queryRole]; + } + } + + $selectedRoles = array_values(array_unique(array_filter($selectedRoles, static fn(string $v): bool => $v !== ''))); + + $confirmDelete = $ctx->postString('confirm_delete') === '1'; + + if ($ctx->isPost() && !$confirmDelete) { + try { + $ctx->requireCsrf('select_delete_users'); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $selectedRoles = []; + } + } + + if ($ctx->isPost() && $confirmDelete) { + try { + $ctx->requireCsrf('delete_users_confirm'); + + if (empty($selectedRoles)) { + throw new RuntimeException('Nie wybrano użytkowników do usunięcia.'); + } + + $deleted = []; + $skipped = []; + + foreach ($selectedRoles as $rawRole) { + try { + $role = NamePolicy::normalizeRoleName($rawRole, $prefix); + if (!in_array($role, $availableRoles, true)) { + $skipped[] = $role . ' (nie istnieje)'; + continue; + } + + $owned = $ctx->pg->roleOwnedDatabases($role, $daUser); + if (!empty($owned)) { + $skipped[] = $role . ' (właściciel baz: ' . implode(', ', $owned) . ')'; + continue; + } + + $assigned = $ctx->pg->roleAssignedDatabases($role, $daUser); + foreach ($assigned as $dbName) { + $owner = $ctx->pg->getDatabaseOwner($dbName); + if ($owner !== null && $owner !== $role) { + $ctx->pg->reassignOwnedObjects($dbName, $role, $owner); + } + $ctx->pg->revokePrivileges($dbName, $role); + } + + $ctx->pg->deleteRoleHosts($daUser, $role); + $ctx->pg->dropRole($role); + $deleted[] = $role; + } catch (Throwable $inner) { + $skipped[] = $rawRole . ' (' . $inner->getMessage() . ')'; + } + } + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Usuwanie wykonane, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + if (!empty($deleted)) { + $ok[] = 'Usunięto użytkowników: ' . implode(', ', $deleted); + } + if (!empty($skipped)) { + $errors[] = 'Nie usunięto: ' . implode('; ', $skipped); + } + + $selectedRoles = []; + $availableRoles = array_map(static fn(array $r): string => $r['name'], $ctx->pg->listRolesForUser($daUser)); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if (empty($selectedRoles)) { + $rows = ''; + foreach ($availableRoles as $roleName) { + $assigned = $ctx->pg->roleAssignedDatabases($roleName, $daUser); + $owned = $ctx->pg->roleOwnedDatabases($roleName, $daUser); + $rows .= ''; + $rows .= ''; + $rows .= '' . AppContext::e($roleName) . ''; + $rows .= '' . AppContext::e(implode(', ', $assigned)) . ''; + $rows .= '' . AppContext::e(implode(', ', $owned)) . ''; + $rows .= ''; + } + if ($rows === '') { + $rows = 'Brak użytkowników do usunięcia.'; + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('select_delete_users'); + $body .= '' . $rows . '
    UżytkownikPrzypisane bazyBazy, których jest właścicielem
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Usuwanie Użytkowników', $body, $ok, $errors); + return; + } + + $confirmRows = ''; + foreach ($selectedRoles as $rawRole) { + try { + $role = NamePolicy::normalizeRoleName($rawRole, $prefix); + $assigned = $ctx->pg->roleAssignedDatabases($role, $daUser); + $owned = $ctx->pg->roleOwnedDatabases($role, $daUser); + $confirmRows .= ''; + $confirmRows .= '' . AppContext::e($role) . ''; + $confirmRows .= '' . AppContext::e(implode(', ', $assigned)) . ''; + $confirmRows .= '' . AppContext::e(implode(', ', $owned)) . ''; + $confirmRows .= ''; + } catch (Throwable $e) { + $errors[] = $rawRole . ': ' . $e->getMessage(); + } + } + + if ($confirmRows === '') { + $ctx->renderPage('Usuwanie Użytkowników', '

    Brak poprawnych użytkowników do usunięcia.

    ', $ok, $errors); + return; + } + + $body = ''; + $body .= '

    Potwierdź usunięcie zaznaczonych użytkowników PostgreSQL. Operacja jest nieodwracalna.

    '; + $body .= '
    '; + $body .= $ctx->csrfField('delete_users_confirm'); + $body .= ''; + $body .= ''; + $body .= $confirmRows; + $body .= '
    UżytkownikPrzypisane bazyBazy, których jest właścicielem
    '; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Potwierdzenie Usuwania Użytkowników', $body, $ok, $errors); +}; diff --git a/exec/handlers/download.php b/exec/handlers/download.php new file mode 100644 index 0000000..b226a1a --- /dev/null +++ b/exec/handlers/download.php @@ -0,0 +1,111 @@ +action(), + $ctx->daUser->username(), + Http::server('REMOTE_ADDR'), + Http::method(), + Http::server('REQUEST_URI'), + $message + ); + @error_log($entry, 3, $pluginErrorLog); + }; + + try { + if (!$ctx->daUser->hasPluginAccess()) { + throw new RuntimeException('Brak dostępu do pluginu.'); + } + $queue = new BackupQueueService($ctx->settings, $ctx->daUser); + $jobId = trim($ctx->queryString('job')); + $backupPath = trim($ctx->queryString('path')); + $downloadPath = ''; + $logLabel = ''; + + if ($jobId !== '') { + $logLabel = 'job_id=' . $jobId; + $downloadPath = $queue->resolveBackupTargetFileForCurrentUser($jobId); + } elseif ($backupPath !== '') { + $logLabel = 'path=' . $backupPath; + $downloadPath = $queue->resolveUserBackupFileForCurrentUser($backupPath); + } else { + throw new RuntimeException('Brak parametru job lub path.'); + } + + // Reuse streaming logic from index handler (simplified) + $log('start ' . $logLabel); + if (!is_file($downloadPath) || !is_readable($downloadPath)) { + throw new RuntimeException('Plik backupu nie istnieje lub brak odczytu.'); + } + $downloadName = basename($downloadPath); + $downloadName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $downloadName) ?? 'backup.sql'; + $contentType = 'application/octet-stream'; + if (preg_match('/\\.sql$/i', $downloadName)) { + $contentType = 'application/sql'; + } elseif (preg_match('/\\.gz$/i', $downloadName)) { + $contentType = 'application/gzip'; + } + + clearstatcache(true, $downloadPath); + $size = @filesize($downloadPath); + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + + if ($rawMode) { + $statusLine = 'HTTP/1.1 200 OK'; + echo $statusLine . "\r\n"; + echo 'Content-Type: ' . $contentType . "\r\n"; + echo 'Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . "\"\r\n"; + echo "X-Content-Type-Options: nosniff\r\n"; + echo "Cache-Control: no-store, no-cache, must-revalidate\r\n"; + echo "Pragma: no-cache\r\n"; + if (is_int($size) && $size > 0) { + echo 'Content-Length: ' . (string)$size . "\r\n"; + } + echo "\r\n"; + } else { + if (headers_sent($hsFile, $hsLine)) { + $log('headers_sent ' . $logLabel . ' file=' . $hsFile . ' line=' . (string)$hsLine); + throw new RuntimeException('Nagłówki zostały już wysłane.'); + } + header_remove('Content-Encoding'); + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . '"'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + if (is_int($size) && $size > 0) { + header('Content-Length: ' . (string)$size); + } + } + + $bytes = @readfile($downloadPath); + $log('done ' . $logLabel . ' bytes=' . (string)$bytes); + exit; + } catch (Throwable $e) { + $log('error message=' . $e->getMessage()); + if ($rawMode) { + echo "HTTP/1.1 404 Not Found\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo 'Nie można pobrać backupu: ' . $e->getMessage(); + } else { + http_response_code(404); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Nie można pobrać backupu: ' . $e->getMessage(); + } + exit; + } +}; diff --git a/exec/handlers/file_picker.php b/exec/handlers/file_picker.php new file mode 100644 index 0000000..9be2b29 --- /dev/null +++ b/exec/handlers/file_picker.php @@ -0,0 +1,18 @@ +settings, $ctx->daUser); + $root = '/home/' . $ctx->daUser->username(); + + if (FilePicker::handleDirList($ctx, $queue, $root)) { + return; + } + if (FilePicker::handleDirCreate($ctx, $root, $ctx->daUser->username())) { + return; + } + + http_response_code(404); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Not Found'; +}; diff --git a/exec/handlers/index.php b/exec/handlers/index.php new file mode 100644 index 0000000..7da990b --- /dev/null +++ b/exec/handlers/index.php @@ -0,0 +1,788 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + $showRemoteHosts = $ctx->settings->allowRemoteHosts(); + $dbLimit = $ctx->daUser->maxDatabases(); + + $generatedPassword = ''; + $generatedRole = ''; + $generatedDb = ''; + $advancedPosted = false; + $formAction = $ctx->postString('form_action'); + $deletePromptDbRaw = ''; + $deletePromptSelectedRolesRaw = []; + $deleteBackupJobIdRaw = ''; + $deleteBackupJobId = ''; + $deleteBackupDbName = ''; + $backupQueue = new BackupQueueService($ctx->settings, $ctx->daUser); + $backupEnabled = $ctx->settings->enableBackup(); + $uploadBackupEnabled = $ctx->settings->enableUploadBackup(); + $pluginErrorLog = PLUGIN_ROOT . '/error.log'; + $logDownload = static function (string $message) use ($pluginErrorLog, $ctx, $daUser): void { + $entry = sprintf( + "[%s] DOWNLOAD action=%s user=%s remote=%s method=%s uri=%s %s\n", + date('c'), + $ctx->action(), + $daUser, + Http::server('REMOTE_ADDR'), + Http::method(), + Http::server('REQUEST_URI'), + $message + ); + @error_log($entry, 3, $pluginErrorLog); + }; + + $streamBackupDownload = static function (string $downloadJobId) use ($backupQueue, $logDownload): void { + $downloadJobId = trim($downloadJobId); + if ($downloadJobId === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + + $logDownload('start job_id=' . $downloadJobId); + $downloadPath = $backupQueue->resolveBackupTargetFileForCurrentUser($downloadJobId); + if (!is_file($downloadPath)) { + throw new RuntimeException('Plik backupu nie istnieje na serwerze.'); + } + if (!is_readable($downloadPath)) { + throw new RuntimeException('Brak uprawnień odczytu pliku backupu.'); + } + + $downloadName = basename($downloadPath); + $downloadName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $downloadName) ?? 'backup.sql'; + $contentType = 'application/octet-stream'; + if (preg_match('/\.sql$/i', $downloadName)) { + $contentType = 'application/sql'; + } elseif (preg_match('/\.gz$/i', $downloadName)) { + $contentType = 'application/gzip'; + } + + clearstatcache(true, $downloadPath); + $contentLength = @filesize($downloadPath); + $logDownload( + 'prepare job_id=' . $downloadJobId . + ' file=' . $downloadPath . + ' size=' . (is_int($contentLength) ? (string)$contentLength : 'unknown') . + ' ob_level=' . (string)ob_get_level() + ); + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + + if (headers_sent($hsFile, $hsLine)) { + $logDownload('headers_sent job_id=' . $downloadJobId . ' file=' . $hsFile . ' line=' . (string)$hsLine); + throw new RuntimeException('Nie można pobrać backupu (nagłówki zostały już wysłane).'); + } + + header_remove('Content-Encoding'); + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . '"'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + if (is_int($contentLength) && $contentLength > 0) { + header('Content-Length: ' . (string)$contentLength); + } + + $bytes = @readfile($downloadPath); + if ($bytes === false) { + throw new RuntimeException('Nie można odczytać pliku backupu do pobrania.'); + } + $logDownload('done job_id=' . $downloadJobId . ' bytes=' . (string)$bytes); + exit; + }; + + $action = Http::getString($_REQUEST, 'action'); + if ($action === 'download_backup') { + try { + $ctx->requireCsrf('download_backup_file_submit'); + $streamBackupDownload(Http::getString($_REQUEST, 'job')); + } catch (Throwable $e) { + $logDownload('error job_id=' . trim(Http::getString($_REQUEST, 'job')) . ' message=' . $e->getMessage()); + http_response_code(400); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Nie można pobrać backupu: ' . $e->getMessage(); + exit; + } + } + + if ($ctx->isPost() && $formAction === 'queue_backup') { + try { + $ctx->requireCsrf('queue_backup_submit'); + if (!$backupEnabled) { + throw new RuntimeException('Tworzenie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('backup_db'), $prefix); + $availableDbs = array_map(static fn(array $db): string => $db['name'], $ctx->pg->listDatabasesForUser($daUser)); + if (!in_array($dbName, $availableDbs, true)) { + throw new RuntimeException('Wskazana baza danych nie należy do konta: ' . $dbName); + } + + $backupQueue->queueBackupJob($dbName); + $ok[] = 'Kopia zapasowa bazy danych ' . $dbName . ' została dodana do kolejki'; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'delete_backup_file') { + try { + $ctx->requireCsrf('delete_backup_file_submit'); + $jobId = trim($ctx->postString('backup_job_id')); + if ($jobId === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + + $deletedPath = $backupQueue->deleteBackupTargetFileForCurrentUser($jobId); + try { + $backupQueue->deleteBackupLogForCurrentUser($jobId); + } catch (Throwable $logErr) { + // Ignore log cleanup failure. + } + $ok[] = 'Usunięto plik backupu: ' . $deletedPath; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'delete_backup_confirm') { + try { + $ctx->requireCsrf('delete_backup_confirm_submit'); + $jobId = trim($ctx->postString('backup_job_id')); + if ($jobId === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + + $deletedPath = $backupQueue->deleteBackupTargetFileForCurrentUser($jobId); + try { + $backupQueue->deleteBackupLogForCurrentUser($jobId); + } catch (Throwable $logErr) { + // Ignore log cleanup failure. + } + $ok[] = 'Usunięto plik backupu: ' . $deletedPath; + $deleteBackupJobIdRaw = ''; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $deleteBackupJobIdRaw = trim($ctx->postString('backup_job_id')); + } + } + + if ($ctx->isPost() && $formAction === 'download_backup_file') { + try { + $ctx->requireCsrf('download_backup_file_submit'); + $streamBackupDownload($ctx->postString('backup_job_id')); + } catch (Throwable $e) { + $logDownload('error job_id=' . trim($ctx->postString('backup_job_id')) . ' message=' . $e->getMessage()); + $errors[] = 'Nie można pobrać backupu: ' . $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'create_database') { + try { + $ctx->requireCsrf('create_database_submit'); + + $limit = $ctx->daUser->maxDatabases(); + $used = $ctx->pg->countDatabasesForUser($daUser); + if ($limit > 0 && $used >= $limit) { + throw new RuntimeException('Osiągnięto limit baz danych dla konta (' . $used . '/' . $limit . ').'); + } + + $dbRaw = trim($ctx->postString('db_suffix')); + if ($dbRaw === '') { + $dbRaw = trim($ctx->postString('db_name')); + } + $dbName = NamePolicy::normalizeDatabaseName($dbRaw, $prefix); + if ($ctx->pg->databaseExists($dbName)) { + throw new RuntimeException('Baza danych już istnieje: ' . $dbName); + } + + $creationMode = strtolower($ctx->postString('creation_mode', 'simple')); + $roleModeRaw = strtolower($ctx->postString('role_mode', 'new')); + $advancedPosted = ($creationMode === 'advanced'); + if (!$advancedPosted) { + $advancedPosted = + trim($ctx->postString('role_name')) !== '' || + trim($ctx->postString('password')) !== '' || + trim($ctx->postString('existing_role')) !== '' || + $roleModeRaw === 'existing'; + } + + $roleMode = ($advancedPosted && $roleModeRaw === 'existing') ? 'existing' : 'new'; + $selectedRole = ''; + $createdRole = false; + $createdDatabase = false; + + if ($roleMode === 'existing') { + $selectedRole = NamePolicy::normalizeRoleName($ctx->postString('existing_role'), $prefix); + if (!$ctx->pg->roleExists($selectedRole)) { + throw new RuntimeException('Wybrany użytkownik PostgreSQL nie istnieje: ' . $selectedRole); + } + } else { + $roleInput = $ctx->postString('role_name'); + if ($roleInput === '') { + $roleInput = $dbName; + } + $selectedRole = NamePolicy::normalizeRoleName($roleInput, $prefix); + if ($ctx->pg->roleExists($selectedRole)) { + throw new RuntimeException('Użytkownik PostgreSQL już istnieje: ' . $selectedRole); + } + + $password = $ctx->postString('password'); + if ($advancedPosted && $password === '') { + throw new RuntimeException('Hasło użytkownika jest wymagane w trybie zaawansowanym.'); + } + if (!$advancedPosted) { + $password = rtrim(strtr(base64_encode(random_bytes(18)), '+/', 'AZ'), '='); + } + if (strlen($password) < 12) { + throw new RuntimeException('Hasło użytkownika musi mieć co najmniej 12 znaków.'); + } + + $ctx->pg->createRole($selectedRole, $password); + $createdRole = true; + $generatedPassword = $password; + } + + try { + $ctx->pg->createDatabase($dbName, $selectedRole); + $createdDatabase = true; + + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + $ctx->pg->grantPrivileges($dbName, $selectedRole, $privileges); + + $hostEntries = $ctx->pg->getRoleHostEntries($daUser, $selectedRole); + $hostMap = []; + foreach ($hostEntries as $entry) { + $hostMap[$entry['host']] = $entry['note']; + } + + $needsHostUpdate = $createdRole; + if (!array_key_exists('localhost', $hostMap)) { + $hostMap['localhost'] = ''; + $needsHostUpdate = true; + } + + $remoteHostEntry = null; + if ($showRemoteHosts && $advancedPosted) { + $remoteHostRaw = trim($ctx->postString('remote_host')); + if ($remoteHostRaw !== '') { + $remoteHost = NamePolicy::normalizeRemoteIpv4AccessPattern($remoteHostRaw); + $remoteComment = NamePolicy::normalizeHostComment($ctx->postString('remote_comment')); + $remoteHostEntry = ['host' => $remoteHost, 'note' => $remoteComment]; + } + } + + if ($needsHostUpdate) { + if (count($hostMap) > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla użytkownika PostgreSQL.'); + } + + $finalEntries = []; + foreach ($hostMap as $host => $note) { + $finalEntries[] = ['host' => $host, 'note' => $note]; + } + $ctx->pg->replaceRoleHostsWithNotes($daUser, $selectedRole, $finalEntries); + } + if ($remoteHostEntry !== null) { + $dbHostEntries = $ctx->pg->getRoleHostEntries($daUser, $selectedRole, $dbName); + $dbHostMap = []; + foreach ($dbHostEntries as $entry) { + $dbHostMap[$entry['host']] = $entry['note']; + } + $dbHostMap[$remoteHostEntry['host']] = $remoteHostEntry['note']; + if (count($dbHostMap) > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla wybranej bazy i użytkownika PostgreSQL.'); + } + $dbFinalEntries = []; + foreach ($dbHostMap as $host => $note) { + $dbFinalEntries[] = ['host' => $host, 'note' => $note]; + } + $ctx->pg->replaceRoleHostsWithNotes($daUser, $selectedRole, $dbFinalEntries, $dbName); + } + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Baza i uprawnienia zapisane, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + $generatedRole = $selectedRole; + $generatedDb = $dbName; + $ok[] = 'Baza danych została utworzona.'; + } catch (Throwable $inner) { + if ($createdDatabase) { + try { + $ctx->pg->dropDatabase($dbName); + } catch (Throwable $ignored) { + } + } + if ($createdRole) { + try { + $ctx->pg->dropRole($selectedRole); + } catch (Throwable $ignored) { + } + } + throw $inner; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'delete_database_confirm') { + try { + $ctx->requireCsrf('delete_database_submit'); + + $deletePromptDbRaw = trim($ctx->postString('delete_db')); + if ($deletePromptDbRaw === '') { + throw new RuntimeException('Nie wskazano bazy danych do usunięcia.'); + } + + $deletePromptSelectedRolesRaw = $ctx->postArray('delete_roles'); + $deleteMode = strtolower(trim($ctx->postString('delete_mode'))); + $dbToDelete = NamePolicy::normalizeDatabaseName($deletePromptDbRaw, $prefix); + + $availableBeforeDelete = array_map(static fn(array $db): string => $db['name'], $ctx->pg->listDatabasesForUser($daUser)); + if (!in_array($dbToDelete, $availableBeforeDelete, true)) { + throw new RuntimeException('Wskazana baza danych nie należy do konta: ' . $dbToDelete); + } + + $assignedRoles = array_values(array_unique($ctx->pg->listDatabaseUsers($daUser, $dbToDelete))); + $singleMatchingRole = count($assignedRoles) === 1 && $assignedRoles[0] === $dbToDelete; + foreach ($assignedRoles as $roleName) { + $ctx->pg->revokePrivileges($dbToDelete, $roleName); + $ctx->pg->deleteRoleHosts($daUser, $roleName, $dbToDelete); + } + + $ctx->pg->dropDatabase($dbToDelete); + + $rolesToDelete = []; + if ($deleteMode === 'with_user' && $singleMatchingRole) { + $rolesToDelete[] = $assignedRoles[0]; + } elseif ($deleteMode !== 'db_only') { + foreach ($deletePromptSelectedRolesRaw as $rawRole) { + $roleName = NamePolicy::normalizeRoleName($rawRole, $prefix); + if (!in_array($roleName, $assignedRoles, true)) { + continue; + } + if (!in_array($roleName, $rolesToDelete, true)) { + $rolesToDelete[] = $roleName; + } + } + } + + $deletedUsers = []; + $skippedUsers = []; + foreach ($rolesToDelete as $roleName) { + $remainingAssigned = array_values(array_unique($ctx->pg->roleAssignedDatabases($roleName, $daUser))); + foreach ($remainingAssigned as $remainingDb) { + $ctx->pg->revokePrivileges($remainingDb, $roleName); + } + + $remainingOwned = array_values(array_unique($ctx->pg->roleOwnedDatabases($roleName, $daUser))); + if (!empty($remainingOwned)) { + $skippedUsers[] = $roleName . ' (jest właścicielem baz: ' . implode(', ', $remainingOwned) . ')'; + continue; + } + + try { + $ctx->pg->deleteRoleHosts($daUser, $roleName); + $ctx->pg->dropRole($roleName); + $deletedUsers[] = $roleName; + } catch (Throwable $dropErr) { + $skippedUsers[] = $roleName . ' (' . $dropErr->getMessage() . ')'; + } + } + + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Usuwanie wykonane, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } + + if (count($deletedUsers) === 1) { + $ok[] = 'Usunięto bazę danych ' . $dbToDelete . ' i użytkownika ' . $deletedUsers[0] . '.'; + } elseif (count($deletedUsers) > 1) { + $listItems = ''; + foreach ($deletedUsers as $deletedUser) { + $listItems .= "\n• " . $deletedUser; + } + $ok[] = 'Usunięto bazę danych ' . $dbToDelete . ' i następujących użytkowników przypisanych do bazy danych ' . $dbToDelete . ':' . $listItems; + } else { + $ok[] = 'Usunięto bazę danych ' . $dbToDelete . '.'; + } + if (!empty($skippedUsers)) { + $errors[] = 'Nie udało się usunąć części użytkowników: ' . implode('; ', $skippedUsers) . '.'; + } + + $deletePromptDbRaw = ''; + $deletePromptSelectedRolesRaw = []; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + if ($deletePromptDbRaw === '') { + $deletePromptDbRaw = trim($ctx->postString('delete_db')); + } + if (empty($deletePromptSelectedRolesRaw)) { + $deletePromptSelectedRolesRaw = $ctx->postArray('delete_roles'); + } + } + } + + $showTableCount = $ctx->settings->enableTableCount(); + $showDbSize = $ctx->settings->countDatabaseSize(); + $databases = $ctx->pg->listDatabasesForUser($daUser, $showDbSize); + $dbUsersMap = $ctx->pg->listDatabaseUsersMap($daUser); + $roles = $ctx->pg->listRolesForUser($daUser); + $roleNames = array_map(static fn(array $r): string => $r['name'], $roles); + $dbCount = count($databases); + $endpoint = $ctx->pg->connectionEndpoint(); + $endpointHost = trim($endpoint['host']) !== '' ? $endpoint['host'] : 'localhost'; + $endpointPort = $endpoint['port'] > 0 ? (string)$endpoint['port'] : '5432'; + $serverVersion = ''; + try { + $serverVersion = $ctx->pg->serverVersion(); + } catch (Throwable $e) { + $serverVersion = ''; + } + $advancedOpen = $advancedPosted || (strtolower($ctx->postString('creation_mode', 'simple')) === 'advanced'); + if (!$ctx->isPost() && $deletePromptDbRaw === '') { + $deletePromptDbRaw = trim($ctx->queryString('delete_db')); + } + if (!$ctx->isPost() && $deleteBackupJobIdRaw === '') { + $deleteBackupJobIdRaw = trim($ctx->queryString('delete_backup')); + } + /** @var array $overlayHtmlById */ + $overlayHtmlById = []; + $deletePromptDb = ''; + $deletePromptRows = ''; + $deletePromptWarnings = []; + $deletePromptSelectedRoles = []; + $deletePromptSingleRoleMode = false; + $deletePromptSingleRoleName = ''; + + $limitText = $dbLimit === 0 ? 'Bez ograniczeń' : (string)$dbLimit; + $usageWarn = ''; + + $tableCounts = []; + if ($showTableCount) { + foreach ($databases as $dbRow) { + $dbName = $dbRow['name']; + try { + $stats = $ctx->pg->getDatabaseObjectStats($dbName); + $tableCounts[$dbName] = (int)$stats['tables']; + } catch (Throwable $e) { + $tableCounts[$dbName] = 0; + } + } + } + + $dbRows = ''; + foreach ($databases as $db) { + $name = $db['name']; + $size = $db['size']; + $users = $dbUsersMap[$name] ?? []; + + $dbRows .= ''; + $dbRows .= '' . AppContext::e($name) . ''; + if ($showDbSize) { + $dbRows .= '' . AppContext::e($size) . ''; + } + $dbRows .= '' . AppContext::e((string)count($users)) . ''; + if ($showTableCount) { + $dbRows .= '' . AppContext::e((string)($tableCounts[$name] ?? 0)) . ''; + } + $dbRows .= ''; + if ($ctx->settings->enableAdminer()) { + $dbRows .= '
    '; + $dbRows .= $ctx->csrfField('open_adminer_submit'); + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= '
    '; + } + if ($backupEnabled) { + $locked = $backupQueue->isDbLocked($name); + if (!$locked) { + $dbRows .= '
    '; + $dbRows .= $ctx->csrfField('queue_backup_submit'); + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= '
    '; + } else { + $dbRows .= ''; + } + } + $dbRows .= 'Zarządzaj'; + $dbRows .= 'Usuń bazę'; + $dbRows .= ''; + $dbRows .= ''; + } + if ($dbRows === '') { + $colspan = 3 + ($showDbSize ? 1 : 0) + ($showTableCount ? 1 : 0); + $dbRows = 'Nie znaleziono baz danych...'; + } + + if ($deletePromptDbRaw !== '') { + try { + $deletePromptDb = NamePolicy::normalizeDatabaseName($deletePromptDbRaw, $prefix); + $dbNames = array_map(static fn(array $db): string => $db['name'], $databases); + if (!in_array($deletePromptDb, $dbNames, true)) { + throw new RuntimeException('Wskazana baza danych nie należy do konta: ' . $deletePromptDb); + } + + foreach ($deletePromptSelectedRolesRaw as $rawRole) { + $roleName = NamePolicy::normalizeRoleName($rawRole, $prefix); + if (!in_array($roleName, $deletePromptSelectedRoles, true)) { + $deletePromptSelectedRoles[] = $roleName; + } + } + + $assignedRoles = array_values(array_unique($ctx->pg->listDatabaseUsers($daUser, $deletePromptDb))); + if (count($assignedRoles) === 1 && $assignedRoles[0] === $deletePromptDb) { + $deletePromptSingleRoleMode = true; + $deletePromptSingleRoleName = $assignedRoles[0]; + } else { + foreach ($assignedRoles as $roleName) { + $assignedDbs = array_values(array_unique($ctx->pg->roleAssignedDatabases($roleName, $daUser))); + $isChecked = in_array($roleName, $deletePromptSelectedRoles, true) ? ' checked' : ''; + $deletePromptRows .= ''; + $deletePromptRows .= ''; + $deletePromptRows .= '' . AppContext::e($roleName) . ''; + + if (empty($assignedDbs)) { + $deletePromptRows .= 'Brak przypisań'; + } else { + $dbList = ''; + foreach ($assignedDbs as $dbName) { + $dbList .= '
  • ' . AppContext::e($dbName) . '
  • '; + } + $deletePromptRows .= '
      ' . $dbList . '
    '; + } + $deletePromptRows .= ''; + + if (count($assignedDbs) > 1) { + $deletePromptWarnings[] = 'Uwaga: wskazany użytkownik jest przypisany do wielu baz danych i jego usunięcie spowoduje brak możliwości dostępu do innych baz danych przy użyciu danych logowania użytkownika ' . $roleName . '.'; + } + } + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $deletePromptDb = ''; + $deletePromptRows = ''; + $deletePromptWarnings = []; + $deletePromptSelectedRoles = []; + $deletePromptSingleRoleMode = false; + $deletePromptSingleRoleName = ''; + } + } + + $roleOptions = ''; + $postedExistingRole = $ctx->postString('existing_role'); + foreach ($roleNames as $roleName) { + $selected = ($postedExistingRole === $roleName) ? ' selected' : ''; + $roleOptions .= ''; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = ($code === 'ALL') ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $successCard = ''; + if ($generatedDb !== '' && $generatedRole !== '') { + $passwordLine = $generatedPassword !== '' + ? '
    Hasło:
    ' . AppContext::e($generatedPassword) . '
    ' + : ''; + + $successCard .= '
    '; + $successCard .= '
    '; + $successCard .= '
    '; + $successCard .= '

    Dane dostępowe do bazy danych

    '; + $successCard .= '
    '; + $successCard .= '
    Nazwa hosta:
    ' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')
    '; + $successCard .= '
    Baza danych:
    ' . AppContext::e($generatedDb) . '
    '; + $successCard .= '
    Nazwa użytkownika:
    ' . AppContext::e($generatedRole) . '
    '; + $successCard .= $passwordLine; + $successCard .= '
    '; + } + + $body = ''; + $body .= $successCard; + $body .= $usageWarn; + + if ($deleteBackupJobIdRaw !== '') { + try { + $deleteBackupJobId = trim($deleteBackupJobIdRaw); + $job = $backupQueue->getJobForCurrentUser($deleteBackupJobId); + if (($job['type'] ?? '') !== 'backup') { + throw new RuntimeException('Wskazane zadanie nie jest zadaniem backupu.'); + } + if (($job['status'] ?? '') !== 'done_ok') { + throw new RuntimeException('Backup nie został zakończony powodzeniem.'); + } + $deleteBackupDbName = (string)($job['db_name'] ?? ''); + if ($deleteBackupDbName === '') { + $deleteBackupDbName = 'nieznana baza'; + } + + $body .= '
    '; + $body .= '
    Czy na pewno chcesz usunąć backup bazy danych - ' . AppContext::e($deleteBackupDbName) . '?
    '; + $body .= '
    '; + $body .= $ctx->csrfField('delete_backup_confirm_submit'); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($deletePromptDb !== '') { + $body .= '
    '; + if ($deletePromptSingleRoleMode && $deletePromptSingleRoleName !== '') { + $body .= '
    Czy na pewno chcesz usunąć następującą bazę danych - ' . AppContext::e($deletePromptDb) . ' i przypisanego do niej użytkownika ' . AppContext::e($deletePromptSingleRoleName) . '.
    '; + } else { + $body .= '
    Czy na pewno chcesz usunąć następującą bazę danych - ' . AppContext::e($deletePromptDb) . '.
    '; + } + $body .= '
    '; + $body .= $ctx->csrfField('delete_database_submit'); + $body .= ''; + $body .= ''; + + if ($deletePromptSingleRoleMode && $deletePromptSingleRoleName !== '') { + $body .= '
    '; + $body .= ''; + $body .= 'Nie'; + $body .= ''; + $body .= '
    '; + } else { + if ($deletePromptRows !== '') { + $body .= '

    Do bazy danych ' . AppContext::e($deletePromptDb) . ' przypisani są użytkownicy, z poniższej listy wybierz użytkowników, których chcesz usunąć.

    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '' . $deletePromptRows . '
    WybierzNazwa użytkownikaPrzypisane Bazy danych
    '; + } else { + $body .= '

    Do wskazanej bazy nie są przypisani użytkownicy PostgreSQL.

    '; + } + + if (!empty($deletePromptWarnings)) { + foreach (array_values(array_unique($deletePromptWarnings)) as $warning) { + $body .= '
    ' . AppContext::e($warning) . '
    '; + } + } + + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + } + $body .= '
    '; + $body .= '
    '; + } + + $dbSuffixValue = $ctx->postString('db_suffix'); + if (strpos($dbSuffixValue, $prefix) === 0) { + $dbSuffixValue = substr($dbSuffixValue, strlen($prefix)); + } + $roleSuffixValue = $ctx->postString('role_name'); + if (strpos($roleSuffixValue, $prefix) === 0) { + $roleSuffixValue = substr($roleSuffixValue, strlen($prefix)); + } + + $body .= '
    '; + $body .= '

    Lista baz danych

    '; + $body .= '
    '; + if ($serverVersion !== '') { + $body .= '' . AppContext::e($ctx->t('Wersja serwera PostgreSQL')) . ': ' . AppContext::e($serverVersion) . ''; + } + $body .= 'Ilość baz danych ' . AppContext::e((string)$dbCount) . '/' . AppContext::e($limitText) . ''; + $body .= '
    '; + $header = 'Nazwa bazy danych'; + if ($showDbSize) { + $header .= 'Rozmiar'; + } + $header .= 'Użytkownicy'; + if ($showTableCount) { + $header .= 'Liczba Tabel'; + } + $header .= 'Akcje'; + $body .= '' . $header . '' . $dbRows . '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '

    Utwórz bazę danych

    '; + $body .= '

    Tryb prosty tworzy użytkownika o tej samej nazwie co baza i automatycznie generuje hasło.

    '; + $body .= '
    '; + $body .= $ctx->csrfField('create_database_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    ' . AppContext::e($prefix) . '
    '; + $body .= '
    '; + $body .= '

    Użytkownik o tej samej nazwie i bezpiecznym haśle zostanie wygenerowany automatycznie.

    '; + + $body .= '
    '; + $body .= 'Tryb zaawansowany'; + $body .= '
    '; + $body .= '
    '; + $roleModeCurrent = strtolower($ctx->postString('role_mode', 'new')); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '
    ' . AppContext::e($prefix) . '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '

    W trybie zaawansowanym hasło jest wymagane.

    '; + $body .= '
    '; + + if ($showRemoteHosts) { + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + + $body .= '
    ' . $privCheckboxes . '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + if (!empty($overlayHtmlById)) { + $body .= implode('', array_values($overlayHtmlById)); + } + + $ctx->renderPage('Zarządzanie bazami danych PostgreSQL', $body, $ok, $errors); +}; diff --git a/exec/handlers/manage_hosts.php b/exec/handlers/manage_hosts.php new file mode 100644 index 0000000..05f82b8 --- /dev/null +++ b/exec/handlers/manage_hosts.php @@ -0,0 +1,224 @@ +settings->allowRemoteHosts()) { + $ctx->renderFatal('Zarządzanie hostami zdalnymi jest wyłączone (`allow_remote_hosts=0`).', 403); + return; + } + + $ok = []; + $errors = []; + $lastSyncOutput = ''; + + $daUser = $ctx->daUser->username(); + $prefix = $ctx->daUser->prefix(); + $postgresPort = $ctx->pg->serverPort(); + + $dbNames = array_map(static fn(array $db): string => $db['name'], $ctx->pg->listDatabasesForUser($daUser)); + if (empty($dbNames)) { + $ctx->renderPage('Hosty Zdalne Użytkownika', '

    Brak baz danych do zarządzania hostami.

    ', $ok, $errors); + return; + } + + $selectedDb = $ctx->postString('db_name'); + if ($selectedDb === '') { + $selectedDb = $ctx->queryString('db'); + } + if ($selectedDb === '') { + $selectedDb = $dbNames[0]; + } + try { + $selectedDb = NamePolicy::normalizeDatabaseName($selectedDb, $prefix); + } catch (Throwable $e) { + $selectedDb = $dbNames[0]; + } + if (!in_array($selectedDb, $dbNames, true)) { + $selectedDb = $dbNames[0]; + } + + $roleNames = $ctx->pg->listDatabaseUsers($daUser, $selectedDb); + $selectedRole = $ctx->queryString('role'); + + if ($ctx->isPost()) { + $selectedRole = $ctx->postString('role_name'); + + try { + $ctx->requireCsrf('manage_hosts_submit'); + + $selectedDb = NamePolicy::normalizeDatabaseName($ctx->postString('db_name', $selectedDb), $prefix); + if (!in_array($selectedDb, $dbNames, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $selectedDb); + } + + $roleNames = $ctx->pg->listDatabaseUsers($daUser, $selectedDb); + $selectedRole = NamePolicy::normalizeRoleName($selectedRole, $prefix); + if (!in_array($selectedRole, $roleNames, true)) { + throw new RuntimeException('Użytkownik PostgreSQL nie ma dostępu do wybranej bazy: ' . $selectedRole); + } + + $currentEntries = $ctx->pg->getRoleHostEntries($daUser, $selectedRole, $selectedDb); + $hostMap = []; + foreach ($currentEntries as $entry) { + $hostMap[$entry['host']] = $entry['note']; + } + + $removeHosts = $ctx->postArray('remove_hosts'); + $removeHosts = array_values(array_unique($removeHosts)); + foreach ($removeHosts as $rawHost) { + $host = trim($rawHost); + if ($host === '') { + continue; + } + if (array_key_exists($host, $hostMap)) { + unset($hostMap[$host]); + } + } + + $addHostRaw = trim($ctx->postString('add_host')); + if ($addHostRaw !== '') { + $addHost = NamePolicy::normalizeRemoteIpv4AccessPattern($addHostRaw); + $addComment = NamePolicy::normalizeHostComment($ctx->postString('add_comment')); + $hostMap[$addHost] = $addComment; + } + + $allowAllHosts = $ctx->postString('allow_all_hosts') === '1'; + if ($allowAllHosts) { + $allHostsComment = NamePolicy::normalizeHostComment($ctx->postString('allow_all_comment')); + $hostMap[NamePolicy::ALL_IPV4_HOST_PATTERN] = $allHostsComment !== '' ? $allHostsComment : 'Dostęp z każdego adresu IPv4'; + } else { + unset($hostMap[NamePolicy::ALL_IPV4_HOST_PATTERN]); + } + + if (count($hostMap) > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla wybranej bazy i użytkownika PostgreSQL.'); + } + + $finalEntries = []; + foreach ($hostMap as $host => $note) { + $finalEntries[] = ['host' => $host, 'note' => $note]; + } + + $ctx->pg->replaceRoleHostsWithNotes($daUser, $selectedRole, $finalEntries, $selectedDb); + $restartPostgresql = $ctx->postString('restart_postgresql') === '1'; + $sync = $ctx->pg->syncHbaRules($restartPostgresql); + $lastSyncOutput = trim((string)($sync['output'] ?? '')); + if (!$sync['ok']) { + $errors[] = 'Hosty zapisane, ale synchronizacja konfiguracji hostów zdalnych nie powiodła się: ' . $sync['output']; + } elseif ($restartPostgresql) { + $ok[] = 'Synchronizacja konfiguracji PostgreSQL zakończona i usługa PostgreSQL została zrestartowana.'; + } elseif (array_key_exists(NamePolicy::ALL_IPV4_HOST_PATTERN, $hostMap)) { + $ok[] = 'Synchronizacja PostgreSQL zakończona. Dla dostępu z każdego hosta port PostgreSQL do otwarcia w CSF: ' . $postgresPort . '.'; + } else { + $ok[] = 'Synchronizacja konfiguracji PostgreSQL i CSF zakończona.'; + } + + $ok[] = 'Zaktualizowano hosty dostępu dla użytkownika ' . $selectedRole . '.'; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + $roleNames = $ctx->pg->listDatabaseUsers($daUser, $selectedDb); + if ($selectedRole !== '') { + try { + $selectedRole = NamePolicy::normalizeRoleName($selectedRole, $prefix); + } catch (Throwable $e) { + $selectedRole = ''; + } + } + + if ($selectedRole === '' && !empty($roleNames)) { + $selectedRole = $roleNames[0]; + } + + $currentEntries = []; + if ($selectedRole !== '') { + $currentEntries = $ctx->pg->getRoleHostEntries($daUser, $selectedRole, $selectedDb); + } + $allHostsEnabled = false; + $allHostsComment = ''; + foreach ($currentEntries as $entry) { + if ($entry['host'] === NamePolicy::ALL_IPV4_HOST_PATTERN) { + $allHostsEnabled = true; + $allHostsComment = $entry['note']; + break; + } + } + + $dbOptions = ''; + foreach ($dbNames as $dbName) { + $sel = ($dbName === $selectedDb) ? ' selected' : ''; + $dbOptions .= ''; + } + + $roleOptions = ''; + foreach ($roleNames as $roleName) { + $sel = ($roleName === $selectedRole) ? ' selected' : ''; + $roleOptions .= ''; + } + + $hostRows = ''; + if (empty($currentEntries)) { + $hostRows = 'Brak zapisanych hostów.'; + } else { + foreach ($currentEntries as $entry) { + $host = $entry['host']; + $note = $entry['note']; + + $hostRows .= ''; + $hostRows .= '' . AppContext::e($host) . ''; + $hostRows .= '' . AppContext::e($note) . ''; + $hostRows .= ''; + $hostRows .= ''; + } + } + + $body = ''; + if ($allHostsEnabled) { + $body .= '
    '; + $body .= 'Uwaga: dla bazy ' . AppContext::e($selectedDb) . ' i użytkownika ' . AppContext::e($selectedRole) . ' włączono dostęp z każdego adresu IPv4. Plugin nie otwiera portu PostgreSQL w CSF dla tego trybu. Port PostgreSQL do otwarcia w CSF: ' . AppContext::e((string)$postgresPort) . '.'; + $body .= '
    '; + } + + $body .= '
    '; + $body .= '

    Status konfiguracji hostów zdalnych

    '; + $body .= '

    Hosty zdalne są włączone przez allow_remote_hosts=1. Zapis hostów uruchamia synchronizację pg_hba, postgresql.conf oraz CSF.

    '; + if ($lastSyncOutput !== '') { + $body .= '

    Ostatni wynik synchronizacji:

    '; + $body .= '
    ' . AppContext::e($lastSyncOutput) . '
    '; + } else { + $body .= '

    Ostatni wynik synchronizacji będzie widoczny po zapisaniu zmian hostów.

    '; + } + $body .= '
    '; + + $body .= '
    '; + $body .= $ctx->csrfField('manage_hosts_submit'); + + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '

    Ta opcja dotyczy tylko wybranej bazy i użytkownika. Plugin skonfiguruje PostgreSQL, ale nie otworzy portu PostgreSQL w CSF. Port PostgreSQL do otwarcia w CSF: ' . AppContext::e((string)$postgresPort) . '.

    '; + $body .= ''; + $body .= '
    '; + + $body .= '

    Aktualne hosty dla wybranej bazy

    '; + $body .= '' . $hostRows . '
    Host/CIDRKomentarzAkcja
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Hosty Zdalne Użytkownika', $body, $ok, $errors); +}; diff --git a/exec/handlers/modify_privileges.php b/exec/handlers/modify_privileges.php new file mode 100644 index 0000000..b40d93d --- /dev/null +++ b/exec/handlers/modify_privileges.php @@ -0,0 +1,216 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $dbNames = array_map(static fn(array $db): string => $db['name'], $ctx->pg->listDatabasesForUser($daUser)); + $roleNames = array_map(static fn(array $r): string => $r['name'], $ctx->pg->listRolesForUser($daUser)); + + if (empty($dbNames)) { + $ctx->renderPage('Zarządzaj bazą danych', '

    Brak baz danych do zarządzania.

    ', $ok, $errors); + return; + } + + $selectedDb = $ctx->postString('db_name'); + if ($selectedDb === '') { + $selectedDb = $ctx->queryString('db'); + } + if ($selectedDb === '') { + $selectedDb = $dbNames[0]; + } + + try { + $selectedDb = NamePolicy::normalizeDatabaseName($selectedDb, $prefix); + } catch (Throwable $e) { + $selectedDb = $dbNames[0]; + } + if (!in_array($selectedDb, $dbNames, true)) { + $selectedDb = $dbNames[0]; + } + + $selectedRole = $ctx->postString('role_name'); + if ($selectedRole === '') { + $selectedRole = $ctx->queryString('role'); + } + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('modify_privileges_submit'); + $syncHbaAfterChange = false; + + $selectedDb = NamePolicy::normalizeDatabaseName($ctx->postString('db_name', $selectedDb), $prefix); + if (!in_array($selectedDb, $dbNames, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $selectedDb); + } + + $action = $ctx->postString('form_action', 'save_privileges'); + if ($action === 'revoke_user') { + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if (!in_array($role, $roleNames, true)) { + throw new RuntimeException('Nieprawidłowy użytkownik PostgreSQL: ' . $role); + } + $ctx->pg->revokePrivileges($selectedDb, $role); + $ctx->pg->deleteRoleHosts($daUser, $role, $selectedDb); + $syncHbaAfterChange = true; + $selectedRole = $role; + $ok[] = 'Odebrano dostęp użytkownikowi ' . $role . ' do bazy ' . $selectedDb . '.'; + } elseif ($action === 'grant_user') { + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if (!in_array($role, $roleNames, true)) { + throw new RuntimeException('Nieprawidłowy użytkownik PostgreSQL: ' . $role); + } + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('grant_privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + $ctx->pg->grantPrivileges($selectedDb, $role, $privileges); + $syncHbaAfterChange = true; + $selectedRole = $role; + $ok[] = 'Przyznano dostęp użytkownikowi ' . $role . ' do bazy ' . $selectedDb . '.'; + } else { + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if (!in_array($role, $roleNames, true)) { + throw new RuntimeException('Nieprawidłowy użytkownik PostgreSQL: ' . $role); + } + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + throw new RuntimeException('Wybierz przynajmniej jedno uprawnienie.'); + } + $ctx->pg->grantPrivileges($selectedDb, $role, $privileges); + $syncHbaAfterChange = true; + $selectedRole = $role; + $ok[] = 'Zaktualizowano uprawnienia użytkownika ' . $role . '.'; + } + + if ($syncHbaAfterChange) { + $sync = $ctx->pg->syncHbaRules(); + if (!$sync['ok']) { + $errors[] = 'Uprawnienia zapisane, ale synchronizacja pg_hba nie powiodła się: ' . $sync['output']; + } else { + $ok[] = 'Synchronizacja pg_hba zakończona.'; + } + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + $dbInfo = $ctx->pg->getDatabaseInfo($selectedDb); + $dbStats = $ctx->pg->getDatabaseObjectStats($selectedDb); + $dbUsers = $ctx->pg->listDatabaseUsers($daUser, $selectedDb); + + if ($selectedRole !== '') { + try { + $selectedRole = NamePolicy::normalizeRoleName($selectedRole, $prefix); + } catch (Throwable $e) { + $selectedRole = ''; + } + } + if ($selectedRole === '' && !empty($dbUsers)) { + $selectedRole = $dbUsers[0]; + } + + $currentProfile = []; + if ($selectedRole !== '') { + $currentProfile = $ctx->pg->getGrantProfile($selectedDb, $selectedRole); + if (empty($currentProfile) && in_array($selectedRole, $dbUsers, true)) { + $currentProfile = ['ALL']; + } + } + + $dbOptions = ''; + foreach ($dbNames as $dbName) { + $sel = ($dbName === $selectedDb) ? ' selected' : ''; + $dbOptions .= ''; + } + + $assignableRoles = array_values(array_diff($roleNames, $dbUsers)); + $assignOptions = ''; + foreach ($assignableRoles as $role) { + $assignOptions .= ''; + } + + $usersRows = ''; + foreach ($dbUsers as $role) { + $profile = $ctx->pg->getGrantProfile($selectedDb, $role); + $label = (empty($profile) || in_array('ALL', $profile, true)) + ? 'Pełny dostęp' + : implode(', ', $profile); + + $usersRows .= ''; + $usersRows .= '' . AppContext::e($role) . ''; + $usersRows .= '' . AppContext::e($label) . ''; + $usersRows .= ''; + $usersRows .= 'Zarządzaj'; + $usersRows .= 'Przywileje'; + $usersRows .= '
    '; + $usersRows .= $ctx->csrfField('modify_privileges_submit'); + $usersRows .= ''; + $usersRows .= ''; + $usersRows .= ''; + $usersRows .= ''; + $usersRows .= '
    '; + $usersRows .= ''; + $usersRows .= ''; + } + if ($usersRows === '') { + $usersRows = 'Brak użytkowników z dostępem do bazy.'; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = in_array($code, $currentProfile, true) ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $body = ''; + $body .= '
    '; + $body .= '

    Zarządzaj bazą danych

    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '

    Szczegółowe informacje o bazie danych.

    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    Nazwa bazy danych
    ' . AppContext::e($dbInfo['name']) . '
    Domyślne kodowanie znaków
    ' . AppContext::e($dbInfo['encoding']) . '
    Domyślne porównywanie znaków
    ' . AppContext::e($dbInfo['collation']) . '
    Rozmiar
    ' . AppContext::e($dbInfo['size']) . '
    Użytkownicy
    ' . AppContext::e((string)count($dbUsers)) . '
    Liczba Tabel
    ' . AppContext::e((string)$dbStats['tables']) . '
    Widoki
    ' . AppContext::e((string)$dbStats['views']) . '
    Wyzwalacze
    ' . AppContext::e((string)$dbStats['triggers']) . '
    Rutyny i procedury
    ' . AppContext::e((string)$dbStats['routines']) . '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '

    Dostęp użytkownika

    '; + $body .= '

    Lista użytkowników, którzy mają dostęp do tej bazy danych wraz z podsumowaniem uprawnień.

    '; + $body .= '' . $usersRows . '
    Nazwa użytkownikaPrzywilejeAkcje
    '; + $body .= '
    '; + $body .= $ctx->csrfField('modify_privileges_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + if ($selectedRole !== '') { + $body .= '
    '; + $body .= '

    Przywileje użytkownika: ' . AppContext::e($selectedRole) . '

    '; + $body .= '
    '; + $body .= $ctx->csrfField('modify_privileges_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    ' . $privCheckboxes . '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + + $ctx->renderPage('Zarządzaj bazą danych', $body, $ok, $errors); +}; diff --git a/exec/handlers/open_adminer.php b/exec/handlers/open_adminer.php new file mode 100644 index 0000000..beecf43 --- /dev/null +++ b/exec/handlers/open_adminer.php @@ -0,0 +1,82 @@ +settings->enableAdminer()) { + $message = 'Integracja Adminera jest wyłączona na tym serwerze.'; + if ($rawMode) { + echo "HTTP/1.1 403 Forbidden\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo $message; + } else { + http_response_code(403); + header('Content-Type: text/plain; charset=UTF-8'); + echo $message; + } + exit; + } + + try { + $ctx->requireCsrf('open_adminer_submit'); + $requestedDb = trim($ctx->postString('adminer_db')); + if ($requestedDb === '') { + $requestedDb = null; + } + $sso = new AdminerSso($ctx->settings, $ctx->daUser, $ctx->pg); + $target = $sso->issueLoginUrl($requestedDb); + $safeTarget = htmlspecialchars($target, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + if ($rawMode) { + echo "HTTP/1.1 200 OK\r\n"; + echo "Content-Type: text/html; charset=UTF-8\r\n"; + echo "Cache-Control: no-store, no-cache, must-revalidate\r\n"; + echo "Pragma: no-cache\r\n\r\n"; + echo ''; + echo ''; + echo 'Przekierowanie do Adminera... Kliknij tutaj'; + } else { + header('Location: ' . $target, true, 302); + } + exit; + } catch (Throwable $e) { + $message = 'Nie można otworzyć Adminera: ' . $e->getMessage(); + @error_log( + sprintf( + "[%s] ADMINER_SSO_FAIL user=%s remote=%s message=%s\n", + date('c'), + $ctx->daUser->username(), + Http::server('REMOTE_ADDR'), + $e->getMessage() + ), + 3, + PLUGIN_ROOT . '/error.log' + ); + + if ($rawMode) { + echo "HTTP/1.1 400 Bad Request\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo $message; + } else { + http_response_code(400); + header('Content-Type: text/plain; charset=UTF-8'); + echo $message; + } + exit; + } +}; diff --git a/exec/handlers/upload_backup.php b/exec/handlers/upload_backup.php new file mode 100644 index 0000000..88915e4 --- /dev/null +++ b/exec/handlers/upload_backup.php @@ -0,0 +1,1290 @@ + $ctx->t($key, $vars); + $et = static fn(string $key, array $vars = []): string => AppContext::e($ctx->t($key, $vars)); + + $daUser = $ctx->daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $queue = new BackupQueueService($ctx->settings, $ctx->daUser); + try { + $queue->ensureInfrastructure(); + } catch (Throwable $e) { + $errors[] = $ctx->formatException($e); + } + + if (!$ctx->settings->enableUploadBackup()) { + $ctx->renderPage( + 'Przywróć Backup', + '

    ' . $et('Obsługa przywracania backupów jest wyłączona (`ENABLE_UPLOAD_BACKUP=false`).') . '

    ', + $ok, + $errors + ); + return; + } + + $jsonResponse = static function (array $payload, int $statusCode = 200): void { + http_response_code($statusCode); + header('Content-Type: text/plain; charset=UTF-8'); + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $json = '{"ok":false,"error":"JSON encode failed"}'; + } + $marker = '__DA_POSTGRESQL_UPLOAD_JSON__'; + echo $marker . "\n" . $json . "\n" . $marker; + }; + + $requestAction = strtolower($ctx->queryString('action')); + if ($ctx->isPost() && $requestAction === 'upload_blob') { + $jsonResponse([ + 'ok' => false, + 'error' => $ctx->t('Tryb przesyłania został zaktualizowany. Odśwież stronę i spróbuj ponownie.'), + ], 409); + return; + } + + $activeTab = strtolower($ctx->postString('active_tab', $ctx->queryString('tab', 'local'))); + if (!in_array($activeTab, ['local', 'user', 'file'], true)) { + $activeTab = 'local'; + } + + $sourceFilePathValue = $ctx->postString('source_file_path'); + if ($sourceFilePathValue === '') { + $rawPath = $_POST['source_file_path'] ?? null; + if (is_array($rawPath) && isset($rawPath[0])) { + $sourceFilePathValue = trim((string)$rawPath[0]); + } + } + if ($sourceFilePathValue === '') { + $sourceFilePathValue = $ctx->queryString('source_file_path'); + } + $uploadedTmpPathValue = $ctx->postString('uploaded_tmp_path', $ctx->queryString('uploaded_tmp_path')); + $uploadedOriginalNameValue = $ctx->postString('uploaded_original_name', $ctx->queryString('uploaded_original_name')); + $sourceLocationMode = strtolower($ctx->postString('source_mode', $ctx->queryString('source_mode', 'local'))); + if (!in_array($sourceLocationMode, ['local', 'server'], true)) { + $sourceLocationMode = 'local'; + } + + $allowRestoreOther = $ctx->settings->allowRestoreToOtherDatabase(); + $restoreMode = strtolower($ctx->postString('restore_mode', $ctx->queryString('restore_mode', 'overwrite'))); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + if (!$allowRestoreOther) { + $restoreMode = 'overwrite'; + } + $restoreTargetDb = trim($ctx->postString('target_db', $ctx->queryString('target_db', ''))); + if ($restoreTargetDb !== '' && strpos($restoreTargetDb, $prefix) !== 0) { + $restoreTargetDb = $prefix . $restoreTargetDb; + } + + $availableDbs = []; + try { + $availableDbs = array_map(static fn(array $db): string => (string)$db['name'], $ctx->pg->listDatabasesForUser($daUser, false)); + } catch (Throwable $e) { + $errors[] = $ctx->formatException($e); + } + if ($restoreTargetDb !== '' && !in_array($restoreTargetDb, $availableDbs, true)) { + $restoreTargetDb = ''; + } + if ($restoreTargetDb === '' && !empty($availableDbs)) { + $restoreTargetDb = $availableDbs[0]; + } + + $renderTargetRadios = function (array $dbs, string $selected, string $name, array $exclude = [], bool $required = false, bool $disabled = false, string $idPrefix = '', string $formId = '') use ($et): string { + $exclude = array_fill_keys($exclude, true); + $filtered = []; + foreach ($dbs as $dbName) { + if (isset($exclude[$dbName])) { + continue; + } + $filtered[] = $dbName; + } + + if (empty($filtered)) { + return '

    ' . $et('Brak baz danych do wyboru.') . '

    '; + } + + if ($selected === '' || !in_array($selected, $filtered, true)) { + $selected = $filtered[0] ?? ''; + } + + $safePrefix = preg_replace('/[^A-Za-z0-9_-]+/', '_', $idPrefix) ?? ''; + $items = '
    '; + foreach ($filtered as $idx => $dbName) { + $id = 'restore-target-' . $safePrefix . '-' . $idx; + $checked = ($dbName === $selected) ? ' checked' : ''; + $req = ($required && $idx === 0) ? ' required' : ''; + $dis = $disabled ? ' disabled' : ''; + $formAttr = $formId !== '' ? ' form="' . AppContext::e($formId) . '"' : ''; + $items .= ''; + } + $items .= '
    '; + return $items; + }; + + $ensureDbExists = static function (AppContext $ctx, string $dbName): void { + try { + $ctx->pg->getDatabaseInfo($dbName); + } catch (Throwable $e) { + throw new RuntimeException('Wskazana docelowa baza danych nie istnieje. Utwórz ją ręcznie w DirectAdmin.'); + } + }; + + $formatBackupLabel = static function (array $entry): string { + $date = (string)($entry['date'] ?? ''); + $time = (string)($entry['time'] ?? ''); + $raw = trim($date . ' ' . $time); + if ($raw === '') { + return '-'; + } + $ts = strtotime($raw); + if ($ts === false) { + return $raw; + } + return date('d-m-Y H:i:s', $ts); + }; + + $displayFileName = static function (string $value): string { + $name = basename(trim($value)); + if ($name === '') { + return ''; + } + if (preg_match('/^stream-[^_]+_(.+)$/', $name, $m) === 1) { + $name = $m[1]; + } + if (preg_match('/^[A-Za-z]+-[0-9]{8,}-[0-9a-f]{6,}_(.+)$/', $name, $m) === 1) { + $name = $m[1]; + } + return $name; + }; + + $renderHiddenInputs = static function (array $fields): string { + $out = ''; + foreach ($fields as $name => $value) { + if (!is_string($name)) { + continue; + } + if (is_array($value)) { + foreach ($value as $item) { + if (is_array($item) || is_object($item)) { + continue; + } + $out .= ''; + } + continue; + } + if (is_object($value)) { + continue; + } + $out .= ''; + } + return $out; + }; + + $renderConfirmOverlay = static function (string $bodyHtml, array $fields) use ($ctx, $et, $renderHiddenInputs): string { + $hidden = $renderHiddenInputs($fields); + $html = ''; + $html .= '
    '; + $html .= '
    '; + $html .= '

    ' . $et('Potwierdzenie operacji') . '

    '; + $html .= $bodyHtml; + $html .= '
    '; + $html .= $ctx->csrfField('upload_backup_submit'); + $html .= $hidden; + $html .= ''; + $html .= '
    '; + $html .= '' . $et('Nie') . ''; + $html .= ''; + $html .= '
    '; + $html .= '
    '; + $html .= '
    '; + return $html; + }; + + $confirmOverlay = ''; + + $findUserBackupEntry = static function (BackupQueueService $queue, string $relPath): ?array { + $relPath = ltrim(trim($relPath), '/'); + if ($relPath === '') { + return null; + } + $all = $queue->listUserBackupsByDate(800); + foreach ($all as $entries) { + foreach ($entries as $entry) { + if (!is_array($entry)) { + continue; + } + if (($entry['rel_path'] ?? '') === $relPath) { + return $entry; + } + } + } + return null; + }; + + $streamUserBackup = static function (string $path): void { + $downloadName = basename($path); + $downloadName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $downloadName) ?? 'backup.sql'; + $contentType = 'application/octet-stream'; + if (preg_match('/\.sql$/i', $downloadName)) { + $contentType = 'application/sql'; + } elseif (preg_match('/\.gz$/i', $downloadName)) { + $contentType = 'application/gzip'; + } + + clearstatcache(true, $path); + $contentLength = @filesize($path); + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + + header_remove('Content-Encoding'); + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . '"'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + if (is_int($contentLength) && $contentLength > 0) { + header('Content-Length: ' . (string)$contentLength); + } + + @readfile($path); + exit; + }; + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('upload_backup_submit'); + $action = $ctx->postString('form_action'); + $restoreMode = strtolower($ctx->postString('restore_mode', $restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + if (!$allowRestoreOther) { + $restoreMode = 'overwrite'; + } + + if ($action === 'restore_local') { + $backupId = $ctx->postString('local_backup'); + $entry = $queue->getLocalBackupEntry($backupId); + $sourceDb = (string)($entry['db_name'] ?? ''); + $backupLabel = $formatBackupLabel($entry); + $confirmAction = $ctx->postString('confirm_action') === '1'; + + $targetDb = $sourceDb; + if ($restoreMode === 'new_db') { + $targetDb = NamePolicy::normalizeDatabaseName($ctx->postString('target_db'), $prefix); + if ($sourceDb !== '' && $targetDb === $sourceDb) { + throw new RuntimeException('Docelowa baza danych musi być inna niż źródłowa baza z backupu.'); + } + $ensureDbExists($ctx, $targetDb); + } + + if (!$confirmAction) { + $sourceHtml = '' . AppContext::e($sourceDb !== '' ? $sourceDb : $targetDb) . ''; + $targetHtml = '' . AppContext::e($targetDb !== '' ? $targetDb : $sourceDb) . ''; + $targetHtmlDanger = '' . AppContext::e($targetDb !== '' ? $targetDb : $sourceDb) . ''; + $dateHtml = '' . AppContext::e($backupLabel) . ''; + $overwriteHtml = '' . $et('nadpisując obecne dane') . ''; + + if ($restoreMode === 'new_db') { + $line1 = $ctx->t( + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z dn. {date} do innej bazy danych o nazwie {target}?', + ['source' => $sourceHtml, 'date' => $dateHtml, 'target' => $targetHtmlDanger] + ); + $line2 = $ctx->t( + 'Uwaga: dane znajdujące się obecnie w bazie danych {target} zostaną usunięte i nadpisane danymi z kopii zapasowej dla bazy {source}.', + ['target' => $targetHtmlDanger, 'source' => $sourceHtml] + ); + $confirmBody = '

    ' . $line1 . '

    ' . $line2 . '

    '; + } else { + $confirmBody = '

    ' . $ctx->t( + 'Czy na pewno chcesz przywrócić kopię zapasową bazy danych {source} z dn. {date} {overwrite} znajdujące się w bazie danych {target}?', + ['source' => $sourceHtml, 'date' => $dateHtml, 'overwrite' => $overwriteHtml, 'target' => $targetHtml] + ) . '

    '; + } + + $confirmOverlay = $renderConfirmOverlay($confirmBody, [ + 'active_tab' => 'local', + 'form_action' => 'restore_local', + 'local_backup' => $backupId, + 'restore_mode' => $restoreMode, + 'target_db' => $targetDb, + ]); + $activeTab = 'local'; + } else { + if ($restoreMode === 'new_db') { + $jobId = $queue->queueRestoreFromLocal($backupId, $restoreMode, $targetDb); + } else { + $jobId = $queue->queueRestoreFromLocal($backupId, $restoreMode, ''); + } + $ok[] = $tr('Dodano zadanie przywracania backupu lokalnego (ID: {id}).', ['id' => $jobId]); + $activeTab = 'local'; + } + } elseif ($action === 'restore_file') { + $targetDb = NamePolicy::normalizeDatabaseName($ctx->postString('target_db'), $prefix); + $ensureDbExists($ctx, $targetDb); + $confirmAction = $ctx->postString('confirm_action') === '1'; + $sourceMode = strtolower($ctx->postString('source_mode', 'local')); + if (!in_array($sourceMode, ['local', 'server'], true)) { + $sourceMode = 'local'; + } + $stagedPath = trim($ctx->postString('uploaded_tmp_path')); + $uploadedOriginalName = trim($ctx->postString('uploaded_original_name')); + $localUploadField = trim($ctx->postString('source_file_upload')); + if ($localUploadField === '') { + $rawUploadField = $_POST['source_file_upload'] ?? null; + if (is_array($rawUploadField) && isset($rawUploadField[0])) { + $localUploadField = trim((string)$rawUploadField[0]); + } elseif (!is_array($rawUploadField) && $rawUploadField !== null) { + $localUploadField = trim((string)$rawUploadField); + } + } + $serverPath = trim($sourceFilePathValue); + if ($sourceMode === 'server' && $serverPath === '') { + throw new RuntimeException('Wskaż ścieżkę do pliku SQL lub SQL.GZ.'); + } + if ($sourceMode === 'local' && $stagedPath === '') { + $uploadFile = $_FILES['source_file_upload'] ?? null; + if (is_array($uploadFile) && !empty($uploadFile) && (int)($uploadFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $stagedPath = $queue->stageRestoreUploadFromPhpUpload($uploadFile, $uploadedOriginalName); + if ($uploadedOriginalName === '') { + $uploadedOriginalName = basename((string)($uploadFile['name'] ?? '')); + } + } elseif ($localUploadField !== '') { + $stagedPath = $queue->stageRestoreUploadFromDirectAdminTemp($localUploadField, $uploadedOriginalName); + } else { + throw new RuntimeException('Nie udało się przygotować pliku do przywrócenia. Wybierz plik ponownie.'); + } + } + if ($sourceMode === 'local' && $stagedPath === '') { + throw new RuntimeException('Nie udało się przygotować pliku do przywrócenia. Wybierz plik ponownie.'); + } + + $fileLabel = ''; + if ($sourceMode === 'local') { + if ($uploadedOriginalName !== '') { + $fileLabel = $displayFileName($uploadedOriginalName); + } + if ($fileLabel === '' && $stagedPath !== '') { + $fileLabel = $displayFileName($stagedPath); + } + } else { + $fileLabel = $displayFileName($serverPath); + } + if ($fileLabel === '') { + $fileLabel = 'backup.sql.gz'; + } + // Bezpieczeństwo: dla restore z pliku nie wykrywamy i nie ufamy nazwie bazy z nazwy pliku. + // Import zawsze ma trafić do bazy docelowej wskazanej przez użytkownika. + $sourceDbName = ''; + $postedRestoreMode = strtolower(trim($ctx->postString('restore_mode'))); + $restoreModeFile = in_array($postedRestoreMode, ['overwrite', 'new_db'], true) ? $postedRestoreMode : 'overwrite'; + if (!$allowRestoreOther) { + $restoreModeFile = 'overwrite'; + } + + @error_log(sprintf( + "[%s] RESTORE_FILE action=%s user=%s source_mode=%s has_files=%s local_field=%s source_path=%s uploaded_tmp=%s uploaded_name=%s source_db=%s target_db=%s restore_mode=%s confirm=%s\n", + date('c'), + $ctx->action(), + $daUser, + $sourceMode, + empty($_FILES) ? '0' : '1', + $localUploadField, + $serverPath, + $stagedPath, + $uploadedOriginalName, + $sourceDbName, + $targetDb, + $restoreModeFile, + $confirmAction ? '1' : '0' + ), 3, PLUGIN_ROOT . '/error.log'); + + if (!$confirmAction) { + $fileHtml = '' . AppContext::e($fileLabel) . ''; + $targetHtmlDanger = '' . AppContext::e($targetDb) . ''; + if ($restoreModeFile === 'new_db' && $sourceDbName !== '') { + $sourceHtml = '' . AppContext::e($sourceDbName) . ''; + $line1 = $ctx->t( + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z pliku {file} do innej bazy danych o nazwie {target}?', + ['source' => $sourceHtml, 'file' => $fileHtml, 'target' => $targetHtmlDanger] + ); + $line2 = $ctx->t( + 'Uwaga: dane znajdujące się obecnie w bazie danych {target} zostaną usunięte i nadpisane danymi z kopii zapasowej dla bazy {source}.', + ['target' => $targetHtmlDanger, 'source' => $sourceHtml] + ); + } else { + $line1 = $ctx->t( + 'Czy na pewno chcesz przywrócić plik kopii zapasowej {file} do bazy danych {target}?', + ['file' => $fileHtml, 'target' => $targetHtmlDanger] + ); + $line2 = $ctx->t( + 'Uwaga: jeśli baza danych {target} zawiera dane, zostaną one nadpisane.', + ['target' => $targetHtmlDanger] + ); + } + $confirmOverlay = $renderConfirmOverlay('

    ' . $line1 . '

    ' . $line2 . '

    ', [ + 'active_tab' => 'file', + 'form_action' => 'restore_file', + 'source_mode' => $sourceMode, + 'source_file_path' => $serverPath, + 'uploaded_tmp_path' => $stagedPath, + 'uploaded_original_name' => ($uploadedOriginalName !== '' ? $uploadedOriginalName : $fileLabel), + 'source_db_name' => $sourceDbName, + 'restore_mode' => $restoreModeFile, + 'target_db' => $targetDb, + ]); + $activeTab = 'file'; + } else { + if ($sourceMode === 'local') { + $jobId = $queue->queueRestoreFromFilePath($targetDb, $stagedPath, $restoreModeFile, $sourceDbName); + } else { + $filePath = $serverPath; + $jobId = $queue->queueRestoreFromFilePath($targetDb, $filePath, $restoreModeFile, $sourceDbName); + } + $ok[] = $tr('Zadanie przywrócenia kopii zapasowej {file} do bazy danych {db} zostało dodane do kolejki', [ + 'file' => $fileLabel, + 'db' => $targetDb, + ]); + $activeTab = 'file'; + } + } elseif ($action === 'download_user_backup') { + $jobId = trim($ctx->postString('backup_job_id')); + $backupPath = trim($ctx->postString('backup_path')); + $path = ''; + if ($jobId !== '') { + $path = $queue->resolveBackupTargetFileForCurrentUser($jobId); + } + if ($path === '' && $backupPath !== '') { + $path = $queue->resolveUserBackupFileForCurrentUser($backupPath); + } + if ($path === '') { + throw new RuntimeException('Nie można odnaleźć pliku backupu.'); + } + $streamUserBackup($path); + } elseif ($action === 'delete_user_backup') { + $jobId = trim($ctx->postString('backup_job_id')); + $backupPath = trim($ctx->postString('backup_path')); + if ($jobId === '' && $backupPath === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + $confirmAction = $ctx->postString('confirm_action') === '1'; + + if (!$confirmAction) { + $dbLabel = 'nieznana baza'; + $dateLabel = '-'; + if ($jobId !== '') { + $job = $queue->getJobForCurrentUser($jobId); + if (($job['type'] ?? '') !== 'backup') { + throw new RuntimeException('Wskazane zadanie nie jest zadaniem backupu.'); + } + $dbLabel = (string)($job['db_name'] ?? '') !== '' ? (string)$job['db_name'] : $dbLabel; + $startTs = (int)($job['start_ts'] ?? 0); + $dateLabel = $startTs > 0 ? date('Y-m-d H:i:s', $startTs) : '-'; + } elseif ($backupPath !== '') { + $entry = $findUserBackupEntry($queue, $backupPath); + if ($entry !== null) { + $dbLabel = (string)($entry['db_name'] ?? '') !== '' ? (string)$entry['db_name'] : $dbLabel; + $ts = (int)($entry['mtime'] ?? 0); + $dateLabel = $ts > 0 ? date('Y-m-d H:i:s', $ts) : $dateLabel; + } + } + $confirmBody = '

    ' . $ctx->t( + 'Czy na pewno chcesz usunąć backup bazy danych {db} z dn. {date}?', + ['db' => '' . AppContext::e($dbLabel) . '', 'date' => '' . AppContext::e($dateLabel) . ''] + ) . '

    '; + $confirmOverlay = $renderConfirmOverlay($confirmBody, [ + 'active_tab' => 'user', + 'form_action' => 'delete_user_backup', + 'backup_job_id' => $jobId, + 'backup_path' => $backupPath, + 'user_month' => $ctx->postString('user_month', $ctx->queryString('user_month')), + 'user_date' => $ctx->postString('user_date', $ctx->queryString('user_date')), + 'user_slot' => $ctx->postString('user_slot', $ctx->queryString('user_slot')), + ]); + $activeTab = 'user'; + } else { + if ($jobId !== '') { + $deletedPath = $queue->deleteBackupTargetFileForCurrentUser($jobId); + try { + $queue->deleteBackupLogForCurrentUser($jobId); + } catch (Throwable $logErr) { + // ignore log cleanup failure + } + } else { + $deletedPath = $queue->deleteUserBackupFileForCurrentUser($backupPath); + } + $ok[] = $tr('Usunięto plik backupu: {path}', ['path' => $deletedPath]); + $activeTab = 'user'; + } + } + } catch (Throwable $e) { + $errors[] = $ctx->formatException($e); + } + } + + $backupsByDate = $queue->listLocalBackupsByDate(); + $availableDates = array_keys($backupsByDate); + $availableMonths = []; + foreach ($availableDates as $dateKey) { + if (strlen($dateKey) >= 7) { + $availableMonths[substr($dateKey, 0, 7)] = true; + } + } + $availableMonths = array_keys($availableMonths); + rsort($availableMonths, SORT_STRING); + + $selectedMonth = trim($ctx->postString('backup_month', $ctx->queryString('month', ''))); + if ($selectedMonth === '' || (!empty($availableMonths) && !in_array($selectedMonth, $availableMonths, true))) { + $selectedMonth = $availableMonths[0] ?? date('Y-m'); + } + + $selectedDate = trim($ctx->postString('backup_date', $ctx->queryString('backup_date'))); + $today = date('Y-m-d'); + $monthRequested = ($ctx->postString('backup_month', '') !== '' || $ctx->queryString('month', '') !== ''); + $dateRequested = ($ctx->postString('backup_date', '') !== '' || $ctx->queryString('backup_date', '') !== ''); + if (!$monthRequested && !$dateRequested && isset($backupsByDate[$today])) { + $selectedMonth = substr($today, 0, 7); + $selectedDate = $today; + } + + if ($selectedDate === '' || !isset($backupsByDate[$selectedDate]) || substr($selectedDate, 0, 7) !== $selectedMonth) { + $selectedDate = ''; + foreach ($availableDates as $dayKey) { + if (substr($dayKey, 0, 7) === $selectedMonth) { + $selectedDate = $dayKey; + break; + } + } + } + + $selectedEntries = ($selectedDate !== '' && isset($backupsByDate[$selectedDate])) ? $backupsByDate[$selectedDate] : []; + $selectedSlot = trim($ctx->postString('backup_slot', $ctx->queryString('backup_slot', ''))); + $timeSlots = []; + foreach ($selectedEntries as $entry) { + $timeLabel = trim((string)($entry['time'] ?? '')); + $mtime = (int)($entry['mtime'] ?? 0); + if ($timeLabel === '' && $mtime > 0) { + $timeLabel = date('H:i:s', $mtime); + } + if ($timeLabel === '') { + $timeLabel = '00:00:00'; + } + if (!isset($timeSlots[$timeLabel])) { + $timeSlots[$timeLabel] = []; + } + $timeSlots[$timeLabel][] = $entry; + } + $hasMultipleSlots = count($timeSlots) > 1; + if ($hasMultipleSlots) { + if ($selectedSlot === '' || !isset($timeSlots[$selectedSlot])) { + $slotKeys = array_keys($timeSlots); + $selectedSlot = $slotKeys[0] ?? ''; + } + } else { + $selectedSlot = ''; + } + $entriesToShow = $hasMultipleSlots && $selectedSlot !== '' ? ($timeSlots[$selectedSlot] ?? []) : $selectedEntries; + + $formatMonthLabel = static function (string $monthValue, string $langCode): string { + if (!preg_match('/^(\\d{4})-(\\d{2})$/', $monthValue, $m)) { + return $monthValue; + } + $year = $m[1]; + $month = (int)$m[2]; + $monthsPl = [ + 1 => 'styczeń', 2 => 'luty', 3 => 'marzec', 4 => 'kwiecień', + 5 => 'maj', 6 => 'czerwiec', 7 => 'lipiec', 8 => 'sierpień', + 9 => 'wrzesień', 10 => 'październik', 11 => 'listopad', 12 => 'grudzień', + ]; + $monthsEn = [ + 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', + 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', + 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December', + ]; + $list = ($langCode === 'pl') ? $monthsPl : $monthsEn; + $name = $list[$month] ?? $monthValue; + return $name . ' ' . $year; + }; + + $formatFullDate = static function (string $dateValue, string $langCode): string { + if (!preg_match('/^(\\d{4})-(\\d{2})-(\\d{2})$/', $dateValue, $m)) { + return $dateValue; + } + $year = (int)$m[1]; + $month = (int)$m[2]; + $day = (int)$m[3]; + $monthsPl = [ + 1 => 'stycznia', 2 => 'lutego', 3 => 'marca', 4 => 'kwietnia', + 5 => 'maja', 6 => 'czerwca', 7 => 'lipca', 8 => 'sierpnia', + 9 => 'września', 10 => 'października', 11 => 'listopada', 12 => 'grudnia', + ]; + if ($langCode === 'pl') { + $monthName = $monthsPl[$month] ?? $m[2]; + return $day . ' ' . $monthName . ' ' . $year; + } + $dt = DateTime::createFromFormat('Y-m-d', $dateValue); + return $dt instanceof DateTime ? $dt->format('F j, Y') : $dateValue; + }; + + $formatTimeLabel = static function (string $timeValue): string { + if (preg_match('/^(\\d{2}:\\d{2})/', $timeValue, $m)) { + return $m[1]; + } + return $timeValue; + }; + + $monthNavTargets = static function (array $availableMonths, string $selectedMonth): array { + $prev = ''; + $next = ''; + $idx = array_search($selectedMonth, $availableMonths, true); + if ($idx !== false) { + if ($idx < count($availableMonths) - 1) { + $prev = $availableMonths[$idx + 1]; + } + if ($idx > 0) { + $next = $availableMonths[$idx - 1]; + } + } + return ['prev' => $prev, 'next' => $next]; + }; + + $calendarWeekdays = static function (string $langCode): array { + if ($langCode === 'pl') { + return ['Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'So', 'Nd']; + } + return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + }; + + $langCode = $ctx->daUser->language(); + $selectedDateLabel = $selectedDate !== '' ? $formatFullDate($selectedDate, $langCode) : ''; + + $userBackupsByDate = $queue->listUserBackupsByDate(500); + $userAvailableDates = array_keys($userBackupsByDate); + $userAvailableMonths = []; + foreach ($userAvailableDates as $dateKey) { + if (strlen($dateKey) >= 7) { + $userAvailableMonths[substr($dateKey, 0, 7)] = true; + } + } + $userAvailableMonths = array_keys($userAvailableMonths); + rsort($userAvailableMonths, SORT_STRING); + + $userSelectedMonth = trim($ctx->postString('user_month', $ctx->queryString('user_month', ''))); + if ($userSelectedMonth === '' || (!empty($userAvailableMonths) && !in_array($userSelectedMonth, $userAvailableMonths, true))) { + $userSelectedMonth = $userAvailableMonths[0] ?? date('Y-m'); + } + + $userSelectedDate = trim($ctx->postString('user_date', $ctx->queryString('user_date'))); + $userMonthRequested = ($ctx->postString('user_month', '') !== '' || $ctx->queryString('user_month', '') !== ''); + $userDateRequested = ($ctx->postString('user_date', '') !== '' || $ctx->queryString('user_date', '') !== ''); + if (!$userMonthRequested && !$userDateRequested && isset($userBackupsByDate[$today])) { + $userSelectedMonth = substr($today, 0, 7); + $userSelectedDate = $today; + } + + if ($userSelectedDate === '' || !isset($userBackupsByDate[$userSelectedDate]) || substr($userSelectedDate, 0, 7) !== $userSelectedMonth) { + $userSelectedDate = ''; + foreach ($userAvailableDates as $dayKey) { + if (substr($dayKey, 0, 7) === $userSelectedMonth) { + $userSelectedDate = $dayKey; + break; + } + } + } + + $userSelectedEntries = ($userSelectedDate !== '' && isset($userBackupsByDate[$userSelectedDate])) ? $userBackupsByDate[$userSelectedDate] : []; + $userSelectedSlot = trim($ctx->postString('user_slot', $ctx->queryString('user_slot', ''))); + $userTimeSlots = []; + foreach ($userSelectedEntries as $entry) { + $timeLabel = trim((string)($entry['time'] ?? '')); + $mtime = (int)($entry['mtime'] ?? 0); + if ($timeLabel === '' && $mtime > 0) { + $timeLabel = date('H:i:s', $mtime); + } + if ($timeLabel === '') { + $timeLabel = '00:00:00'; + } + if (!isset($userTimeSlots[$timeLabel])) { + $userTimeSlots[$timeLabel] = []; + } + $userTimeSlots[$timeLabel][] = $entry; + } + $userHasMultipleSlots = count($userTimeSlots) > 1; + if ($userHasMultipleSlots) { + if ($userSelectedSlot === '' || !isset($userTimeSlots[$userSelectedSlot])) { + $slotKeys = array_keys($userTimeSlots); + $userSelectedSlot = $slotKeys[0] ?? ''; + } + } else { + $userSelectedSlot = ''; + } + $userEntriesToShow = $userHasMultipleSlots && $userSelectedSlot !== '' ? ($userTimeSlots[$userSelectedSlot] ?? []) : $userSelectedEntries; + $userSelectedDateLabel = $userSelectedDate !== '' ? $formatFullDate($userSelectedDate, $langCode) : ''; + + $jobs = $queue->listJobsForCurrentUser(500); + + $tabs = ''; + $tabs .= ''; + + $restoreModeSection = ''; + if ($activeTab === 'local' && $allowRestoreOther) { + $restoreModeSection .= '
    '; + $restoreModeSection .= '

    ' . $et('Tryb przywracania') . '

    '; + $restoreModeSection .= '
    '; + $restoreModeSection .= ''; + $restoreModeSection .= ''; + $restoreModeSection .= '
    '; + $restoreModeSection .= '
    '; + } + + $renderRestorePicker = function () use ( + $ctx, + $selectedEntries, + $entriesToShow, + $hasMultipleSlots, + $timeSlots, + $selectedSlot, + $selectedDateLabel, + $selectedDate, + $selectedMonth, + $restoreMode, + $allowRestoreOther, + $availableDbs, + $restoreTargetDb, + $renderTargetRadios, + $formatTimeLabel, + $et + ): string { + $html = ''; + if (empty($selectedEntries)) { + $html .= '

    ' . $et('Brak backupów HITME.PL dla wybranej daty.') . '

    '; + return $html; + } + + if ($selectedDateLabel !== '') { + $html .= '
    ' . $et('Data:') . ' ' . AppContext::e($selectedDateLabel) . '
    '; + } + + if ($hasMultipleSlots) { + $html .= '
    '; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= '
    ' . $et('Wybierz godzinę backupu') . '
    '; + $html .= '
    '; + foreach ($timeSlots as $slotKey => $_entries) { + $timeLabel = $formatTimeLabel($slotKey); + $checked = ($slotKey === $selectedSlot) ? ' checked' : ''; + $html .= ''; + } + $html .= '
    '; + } + + $showTargetColumn = $allowRestoreOther; + $rows = ''; + foreach ($entriesToShow as $entry) { + $sourceDb = (string)($entry['source_db'] ?? ''); + $formId = 'restore-local-' . preg_replace('/[^A-Za-z0-9_-]+/', '_', (string)$entry['id']); + $timeLabel = $formatTimeLabel(trim((string)($entry['time'] ?? ''))); + $dateLabel = $selectedDateLabel !== '' ? $selectedDateLabel : (string)$entry['date']; + if ($hasMultipleSlots && $timeLabel !== '') { + $dateLabel .= ' ' . $timeLabel; + } + + $rows .= ''; + $rows .= '' . AppContext::e(trim($dateLabel)) . ''; + $rows .= '' . AppContext::e($sourceDb !== '' ? $sourceDb : '-') . ''; + if ($showTargetColumn) { + $rows .= ''; + if ($sourceDb !== '') { + $rows .= '
    '; + $rows .= $renderTargetRadios($availableDbs, $restoreTargetDb, 'target_db', [$sourceDb], $restoreMode === 'new_db', $restoreMode !== 'new_db', $entry['id'], $formId); + $rows .= '
    '; + } else { + $rows .= '' . $et('Nie można ustalić bazy z nazwy pliku.') . ''; + } + $rows .= ''; + } + $rows .= '' . AppContext::e($entry['file']) . ''; + $rows .= '' . AppContext::e($entry['size']) . ''; + $rows .= ''; + $rows .= '
    '; + $rows .= $ctx->csrfField('upload_backup_submit'); + $rows .= ''; + $rows .= ''; + $rows .= ''; + $rows .= ''; + if ($sourceDb !== '') { + $rows .= ''; + } else { + $rows .= ''; + } + $rows .= '
    '; + $rows .= ''; + $rows .= ''; + } + + $html .= ''; + $header = ''; + if ($showTargetColumn) { + $header .= ''; + } + $header .= ''; + $html .= '' . $header . ''; + $html .= '' . $rows . '
    ' . $et('Data i godzina') . '' . $et('Baza źródłowa') . '' . $et('Baza docelowa') . '' . $et('Plik') . '' . $et('Rozmiar') . '' . $et('Akcja') . '
    '; + return $html; + }; + + $renderUserBackupPicker = function () use ( + $ctx, + $userSelectedEntries, + $userEntriesToShow, + $userHasMultipleSlots, + $userTimeSlots, + $userSelectedSlot, + $userSelectedDateLabel, + $userSelectedDate, + $userSelectedMonth, + $formatTimeLabel, + $et + ): string { + $html = ''; + if (empty($userSelectedEntries)) { + $html .= '

    ' . $et('Brak backupów użytkownika dla wybranej daty.') . '

    '; + return $html; + } + + if ($userSelectedDateLabel !== '') { + $html .= '
    ' . $et('Data:') . ' ' . AppContext::e($userSelectedDateLabel) . '
    '; + } + + if ($userHasMultipleSlots) { + $html .= '
    '; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= '
    ' . $et('Wybierz godzinę backupu') . '
    '; + $html .= '
    '; + foreach ($userTimeSlots as $slotKey => $_entries) { + $timeLabel = $formatTimeLabel($slotKey); + $checked = ($slotKey === $userSelectedSlot) ? ' checked' : ''; + $html .= ''; + } + $html .= '
    '; + } + + $rows = ''; + foreach ($userEntriesToShow as $entry) { + $jobId = (string)($entry['job_id'] ?? ''); + $dbName = (string)($entry['db_name'] ?? ''); + $hasFile = (bool)($entry['has_file'] ?? false); + $relPath = (string)($entry['rel_path'] ?? ''); + $overlayId = 'log-overlay-' . preg_replace('/[^A-Za-z0-9_-]+/', '-', $jobId); + + $rows .= ''; + $rows .= '' . AppContext::e($dbName !== '' ? $dbName : '-') . ''; + $rows .= ''; + + if ($hasFile) { + $rows .= '
    '; + if ($jobId !== '') { + $rows .= ''; + } elseif ($relPath !== '') { + $rows .= ''; + } + $rows .= ''; + $rows .= '
    '; + } + + if ($jobId !== '') { + $rows .= ''; + } + + if ($hasFile) { + $rows .= '
    '; + $rows .= $ctx->csrfField('upload_backup_submit'); + $rows .= ''; + $rows .= ''; + if ($jobId !== '') { + $rows .= ''; + } + if ($relPath !== '') { + $rows .= ''; + } + $rows .= ''; + $rows .= ''; + $rows .= ''; + $rows .= ''; + $rows .= '
    '; + } + + $rows .= ''; + $rows .= ''; + } + + $html .= ''; + $html .= ''; + $html .= '' . $rows . '
    ' . $et('Nazwa bazy danych') . '' . $et('Akcje') . '
    '; + return $html; + }; + + if ($ctx->queryString('action') === 'local_picker') { + header('Content-Type: text/html; charset=UTF-8'); + echo $renderRestorePicker(); + return; + } + + if ($ctx->queryString('action') === 'user_picker') { + header('Content-Type: text/html; charset=UTF-8'); + echo $renderUserBackupPicker(); + return; + } + + $localSection = ''; + if ($activeTab === 'local') { + $localSection .= '
    '; + $localSection .= '

    ' . $et('Backupy HITME.PL') . '

    '; + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= '

    ' . $et('Kalendarz backupów') . '

    '; + + $nav = $monthNavTargets($availableMonths, $selectedMonth); + $langCode = $ctx->daUser->language(); + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= '
    '; + $localSection .= '
    ' . AppContext::e($formatMonthLabel($selectedMonth, $langCode)) . '
    '; + $localSection .= '
    '; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= '
    '; + $localSection .= '
    '; + + $monthTs = strtotime($selectedMonth . '-01'); + if ($monthTs === false) { + $monthTs = strtotime(date('Y-m-01')); + } + $calendarDays = (int)date('t', $monthTs); + $calendarOffset = (int)date('N', $monthTs); + $weekdays = $calendarWeekdays($langCode); + + $localSection .= '
    '; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + foreach ($weekdays as $dayLabel) { + $localSection .= ''; + } + $localSection .= ''; + $dayNum = 1; + for ($row = 0; $row < 6; $row++) { + $localSection .= ''; + for ($col = 1; $col <= 7; $col++) { + if (($row === 0 && $col < $calendarOffset) || $dayNum > $calendarDays) { + $localSection .= ''; + continue; + } + $dayKey = date('Y-m-', $monthTs) . str_pad((string)$dayNum, 2, '0', STR_PAD_LEFT); + $hasBackup = isset($backupsByDate[$dayKey]); + $isSelected = ($dayKey === $selectedDate); + $localSection .= ''; + $dayNum++; + } + $localSection .= ''; + if ($dayNum > $calendarDays) { + break; + } + } + $localSection .= '
    ' . AppContext::e($dayLabel) . '
    '; + if ($hasBackup) { + $localSection .= ''; + } else { + $localSection .= '' . AppContext::e((string)$dayNum) . ''; + } + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= '
    '; + + $localSection .= '
    '; + $localSection .= $renderRestorePicker(); + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= '
    '; + } + + $userSection = ''; + if ($activeTab === 'user') { + $userSection .= '
    '; + $userSection .= '

    ' . $et('Backupy użytkownika') . '

    '; + $userSection .= '
    '; + $userSection .= '
    '; + $userSection .= '

    ' . $et('Kalendarz backupów') . '

    '; + + $userNav = $monthNavTargets($userAvailableMonths, $userSelectedMonth); + $userSection .= '
    '; + $userSection .= '
    '; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= '
    '; + $userSection .= '
    ' . AppContext::e($formatMonthLabel($userSelectedMonth, $langCode)) . '
    '; + $userSection .= '
    '; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= '
    '; + $userSection .= '
    '; + + $userMonthTs = strtotime($userSelectedMonth . '-01'); + if ($userMonthTs === false) { + $userMonthTs = strtotime(date('Y-m-01')); + } + $userCalendarDays = (int)date('t', $userMonthTs); + $userCalendarOffset = (int)date('N', $userMonthTs); + $weekdays = $calendarWeekdays($langCode); + + $userSection .= '
    '; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + foreach ($weekdays as $dayLabel) { + $userSection .= ''; + } + $userSection .= ''; + $dayNum = 1; + for ($row = 0; $row < 6; $row++) { + $userSection .= ''; + for ($col = 1; $col <= 7; $col++) { + if (($row === 0 && $col < $userCalendarOffset) || $dayNum > $userCalendarDays) { + $userSection .= ''; + continue; + } + $dayKey = date('Y-m-', $userMonthTs) . str_pad((string)$dayNum, 2, '0', STR_PAD_LEFT); + $hasBackup = isset($userBackupsByDate[$dayKey]); + $isSelected = ($dayKey === $userSelectedDate); + $userSection .= ''; + $dayNum++; + } + $userSection .= ''; + if ($dayNum > $userCalendarDays) { + break; + } + } + $userSection .= '
    ' . AppContext::e($dayLabel) . '
    '; + if ($hasBackup) { + $userSection .= ''; + } else { + $userSection .= '' . AppContext::e((string)$dayNum) . ''; + } + $userSection .= '
    '; + $userSection .= '
    '; + $userSection .= '
    '; + + $userSection .= '
    '; + $userSection .= $renderUserBackupPicker(); + $userSection .= '
    '; + + $userSection .= '
    '; + $userSection .= '
    '; + } + + $fileSection = ''; + if ($activeTab === 'file') { + $fileSection .= '
    '; + $fileSection .= '

    ' . $et('Backupy z pliku') . '

    '; + $fileSection .= '

    ' . AppContext::e($tr('Wskaż plik `.sql`, `.gz` lub `.sql.gz` z komputera albo podaj ścieżkę pliku na serwerze (w katalogu `/home/{user}`).', ['user' => $daUser])) . '

    '; + $fileSection .= '
    '; + $fileSection .= $ctx->csrfField('upload_backup_submit'); + $fileSection .= ''; + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= ''; + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= ''; + $fileSection .= ''; + + $fileSection .= ''; + $fileSection .= '
    ' . $et('Po dodaniu pliku zostanie on przesłany na serwer i dodany do kolejki przywracania.') . '
    '; + + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= '
    ' . $renderTargetRadios($availableDbs, $restoreTargetDb, 'target_db', [], true, false, 'file') . '
    '; + $fileSection .= '
    '; + $fileSection .= '
    '; + $fileSection .= '
    '; + $fileSection .= '
    '; + + $fileSection .= FilePicker::renderOverlay( + $ctx, + 'file-picker-overlay', + '/home/' . $daUser, + $ctx->url('file_picker.html'), + 'source-file-path' + ); + } + + $statusRows = ''; + $logOverlays = []; + foreach ($jobs as $job) { + $statusLabel = match ($job['status']) { + 'pending' => $tr('Oczekuje'), + 'processing' => $tr('W toku'), + 'done_ok' => $tr('Zakończone OK'), + 'done_fail' => $tr('Błąd'), + 'done_cancel' => $tr('Anulowane'), + default => $job['status'], + }; + $jobType = (string)($job['type'] ?? ''); + $operationLabel = ($jobType === 'backup') ? $tr('Tworzenie kopii zapasowej') : $tr('Przywracanie kopii zapasowej'); + $restoreModeRaw = strtolower(trim((string)($job['restore_mode'] ?? ''))); + $restoreModeLabel = $jobType === 'restore' + ? ($restoreModeRaw === 'new_db' ? $tr('Przywracanie do nowej bazy') : $tr('Przywracanie z nadpisaniem')) + : $tr('Nie dotyczy'); + + $jobId = (string)($job['job_id'] ?? ''); + $overlayId = 'log-overlay-' . preg_replace('/[^A-Za-z0-9_-]+/', '-', $jobId); + $contentId = $overlayId . '-content'; + $sourceDbName = trim((string)($job['source_db_name'] ?? '')); + $targetDbName = (string)($job['db_name'] ?? '-'); + $hasDistinctSourceTarget = ($jobType === 'restore' && $sourceDbName !== '' && $sourceDbName !== $targetDbName); + $dbDisplay = $hasDistinctSourceTarget + ? ($sourceDbName . ' → ' . $targetDbName) + : $targetDbName; + $logText = ''; + try { + $logText = $queue->readLogForCurrentUser($jobId); + } catch (Throwable $e) { + $logText = $ctx->formatException($e); + } + + $statusRows .= ''; + $statusRows .= '' . AppContext::e($job['start_ts'] > 0 ? date('Y-m-d H:i:s', $job['start_ts']) : '-') . ''; + $statusRows .= '' . AppContext::e($dbDisplay) . ''; + $statusRows .= '' . AppContext::e($operationLabel) . ''; + $statusRows .= '' . AppContext::e($restoreModeLabel) . ''; + $statusRows .= '' . AppContext::e($statusLabel) . ''; + $statusRows .= ''; + $statusRows .= ''; + $statusRows .= ''; + $statusRows .= ''; + + $logMeta = '
    '; + $logMeta .= '
    ' . $et('Data rozpoczęcia') . ': ' . AppContext::e($job['start_ts'] > 0 ? date('Y-m-d H:i:s', $job['start_ts']) : '-') . '
    '; + $logMeta .= '
    ' . $et('Rodzaj operacji') . ': ' . AppContext::e($operationLabel) . '
    '; + $logMeta .= '
    ' . $et('Typ przywracania') . ': ' . AppContext::e($restoreModeLabel) . '
    '; + if ($hasDistinctSourceTarget) { + $logMeta .= '
    ' . $et('Baza źródłowa') . ': ' . AppContext::e($sourceDbName !== '' ? $sourceDbName : '-') . '
    '; + $logMeta .= '
    ' . $et('Baza docelowa') . ': ' . AppContext::e($targetDbName) . '
    '; + } else { + $logMeta .= '
    ' . $et('Nazwa bazy') . ': ' . AppContext::e($targetDbName) . '
    '; + } + $logMeta .= '
    ' . $et('Status') . ': ' . AppContext::e($statusLabel) . '
    '; + $logMeta .= '
    '; + + $logOverlays[] = ''; + } + if ($statusRows === '') { + $statusRows = '' . $et('Brak zadań backup/restore do wyświetlenia.') . ''; + } + + $statusTable = '' . $statusRows . '
    ' . $et('Data rozpoczęcia') . '' . $et('Nazwa bazy') . '' . $et('Rodzaj operacji') . '' . $et('Typ przywracania') . '' . $et('Status') . '' . $et('Akcja') . '
    '; + $statusInner = $statusTable . (!empty($logOverlays) ? implode('', $logOverlays) : ''); + + if ($ctx->queryString('action') === 'status') { + header('Content-Type: text/html; charset=UTF-8'); + echo $statusInner; + return; + } + + $logsSection = ''; + $logsSection .= '
    '; + $logsSection .= '

    ' . $et('Kolejka zadań i logi') . '

    '; + $logsSection .= '
    ' . $statusInner . '
    '; + $logsSection .= '
    '; + + $body = $tabs . $restoreModeSection . $localSection . $userSection . $fileSection . $confirmOverlay; + $shouldRefresh = false; + foreach ($jobs as $job) { + if (in_array($job['status'], ['pending', 'processing'], true)) { + $shouldRefresh = true; + break; + } + } + if ($shouldRefresh) { + $body .= '
    '; + } + $ctx->renderPage('Przywróć Backup', $body, $ok, $errors, $logsSection); +}; diff --git a/exec/lib/AdminerRuntimeConfig.php b/exec/lib/AdminerRuntimeConfig.php new file mode 100644 index 0000000..3afacb1 --- /dev/null +++ b/exec/lib/AdminerRuntimeConfig.php @@ -0,0 +1,61 @@ +adminerSsoDir(), '/'); + if ($runtimeDir === '' || !is_dir($runtimeDir)) { + return; + } + + if (!is_writable($runtimeDir)) { + self::log($pluginErrorLog, 'Adminer runtime dir is not writable: ' . $runtimeDir); + return; + } + + $payload = [ + 'version' => 1, + 'adminer_public' => ($settings->enableAdminer() && $settings->adminerPublic()), + 'updated_at' => time(), + ]; + + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); + if (!is_string($encoded) || $encoded === '') { + self::log($pluginErrorLog, 'Failed to encode Adminer runtime config JSON.'); + return; + } + + $targetPath = $runtimeDir . '/' . self::FILE_NAME; + $tempPath = $runtimeDir . '/.' . self::FILE_NAME . '.' . getmypid() . '.tmp'; + + $written = @file_put_contents($tempPath, $encoded, LOCK_EX); + if ($written === false) { + self::log($pluginErrorLog, 'Failed to write temporary Adminer runtime config: ' . $tempPath); + return; + } + + @chmod($tempPath, 0644); + + if (!@rename($tempPath, $targetPath)) { + @unlink($tempPath); + self::log($pluginErrorLog, 'Failed to replace Adminer runtime config: ' . $targetPath); + return; + } + + @chmod($targetPath, 0644); + } + + private static function log(string $pluginErrorLog, string $message): void + { + if ($pluginErrorLog === '') { + return; + } + + $line = sprintf("[%s] ADMINER_RUNTIME_CONFIG %s\n", date('c'), $message); + @error_log($line, 3, $pluginErrorLog); + } +} diff --git a/exec/lib/AdminerSso.php b/exec/lib/AdminerSso.php new file mode 100644 index 0000000..3c0fe96 --- /dev/null +++ b/exec/lib/AdminerSso.php @@ -0,0 +1,326 @@ +settings = $settings; + $this->daUser = $daUser; + $this->pg = $pg; + $this->runtimeDir = rtrim($settings->adminerSsoDir(), '/'); + $this->ticketsDir = $this->runtimeDir . '/tickets'; + } + + public function issueLoginUrl(?string $requestedDatabase = null): string + { + if (!$this->settings->enableAdminer()) { + throw new RuntimeException('Integracja Adminera jest wyłączona.'); + } + + $this->assertRuntimeReady(); + $now = time(); + $this->cleanupTicketFiles($now); + $this->pg->cleanupExpiredAdminerRoles(self::TMP_ROLE_PREFIX); + + $databaseRows = $this->pg->listDatabasesForUser($this->daUser->username(), false); + if (empty($databaseRows)) { + throw new RuntimeException('Użytkownik nie ma żadnej bazy PostgreSQL do otwarcia w Adminerze.'); + } + + $databaseNames = []; + foreach ($databaseRows as $row) { + $name = (string)($row['name'] ?? ''); + if ($name !== '') { + $databaseNames[] = $name; + } + } + $databaseNames = array_values(array_unique($databaseNames)); + if (empty($databaseNames)) { + throw new RuntimeException('Nie znaleziono poprawnych nazw baz danych dla użytkownika.'); + } + + $ticketTtl = $this->settings->adminerTicketTtl(); + $sessionTtl = $this->settings->adminerSessionTtl(); + $roleTtl = max($this->settings->adminerRoleTtl(), $sessionTtl + 120); + + $ticketExpiresAt = $now + $ticketTtl; + $sessionExpiresAt = $now + $sessionTtl; + $roleExpiresAt = $now + $roleTtl; + + $role = $this->generateTemporaryRoleName(); + $password = $this->generateSecret(36); + $endpoint = $this->pg->connectionEndpoint(); + $targetDatabase = ''; + if ($requestedDatabase !== null) { + $requestedDatabase = trim($requestedDatabase); + if ($requestedDatabase !== '') { + if (!in_array($requestedDatabase, $databaseNames, true)) { + throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.'); + } + $targetDatabase = $requestedDatabase; + } + } + + $createdRole = false; + try { + $this->pg->createTemporaryRole($role, $password, $roleExpiresAt); + $createdRole = true; + + foreach ($databaseNames as $dbName) { + $this->pg->grantTemporaryAdminerAccess($dbName, $role); + } + + $ticketId = $this->generateTicketId(); + $payload = [ + 'v' => 1, + 'da_user' => $this->daUser->username(), + 'host' => (string)($endpoint['host'] ?? 'localhost'), + 'port' => (int)($endpoint['port'] ?? 5432), + 'role' => $role, + 'password' => $password, + 'database' => $targetDatabase, + 'issued_at' => $now, + 'expires_at' => $ticketExpiresAt, + 'session_expires_at' => $sessionExpiresAt, + 'role_expires_at' => $roleExpiresAt, + 'user_binding' => $this->buildUserBinding(), + ]; + $this->writeTicket($ticketId, $payload); + + $targetBase = $this->buildAdminerBaseUrl(); + $separator = str_contains($targetBase, '?') ? '&' : '?'; + return $targetBase . $separator . 'ticket=' . rawurlencode($ticketId); + } catch (Throwable $e) { + if ($createdRole) { + try { + $this->pg->dropTemporaryRole($role); + } catch (Throwable $cleanupError) { + // Ignorujemy błąd cleanupu roli, aby zwrócić pierwotny wyjątek. + } + } + + throw new RuntimeException('Nie udało się przygotować sesji Adminera: ' . $e->getMessage(), 0, $e); + } + } + + private function assertRuntimeReady(): void + { + if (!is_dir($this->runtimeDir)) { + throw new RuntimeException('Brak katalogu runtime Adminera: ' . $this->runtimeDir . '. Uruchom scripts/setup/adminer_install.sh'); + } + + if (!is_dir($this->ticketsDir)) { + throw new RuntimeException('Brak katalogu ticketów Adminera: ' . $this->ticketsDir . '. Uruchom scripts/setup/adminer_install.sh'); + } + + if (!is_readable($this->ticketsDir) || !is_writable($this->ticketsDir)) { + throw new RuntimeException('Brak uprawnień odczytu/zapisu do katalogu ticketów: ' . $this->ticketsDir); + } + } + + private function cleanupTicketFiles(int $now): void + { + $files = glob($this->ticketsDir . '/*.json'); + if ($files === false) { + return; + } + + $hardTtl = max($this->settings->adminerRoleTtl(), 3600); + foreach ($files as $path) { + if (!is_file($path)) { + continue; + } + + $remove = false; + $raw = @file_get_contents($path); + if (is_string($raw) && $raw !== '') { + $data = json_decode($raw, true); + if (is_array($data) && isset($data['expires_at']) && is_numeric($data['expires_at'])) { + $expiresAt = (int)$data['expires_at']; + if ($expiresAt < ($now - 60)) { + $remove = true; + } + } + } + + if (!$remove) { + $mtime = @filemtime($path); + if ($mtime !== false && $mtime < ($now - $hardTtl)) { + $remove = true; + } + } + + if ($remove) { + @unlink($path); + } + } + } + + /** + * @param array $payload + */ + private function writeTicket(string $ticketId, array $payload): void + { + $path = $this->ticketsDir . '/' . $ticketId . '.json'; + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new RuntimeException('Nie udało się zakodować ticketu Adminera.'); + } + + $handle = @fopen($path, 'xb'); + if (!is_resource($handle)) { + throw new RuntimeException('Nie udało się utworzyć ticketu Adminera: ' . $path); + } + + $written = @fwrite($handle, $encoded); + @fclose($handle); + if ($written === false || $written < strlen($encoded)) { + @unlink($path); + throw new RuntimeException('Nie udało się zapisać ticketu Adminera.'); + } + + $this->alignTicketGroup($path); + @chmod($path, 0644); + } + + private function alignTicketGroup(string $path): void + { + $dirGroupId = @filegroup($this->ticketsDir); + if (!is_int($dirGroupId) || $dirGroupId < 0) { + return; + } + + $targetGroup = null; + if (function_exists('posix_getgrgid')) { + $groupInfo = @posix_getgrgid($dirGroupId); + if (is_array($groupInfo) && isset($groupInfo['name']) && is_string($groupInfo['name'])) { + $targetGroup = $groupInfo['name']; + } + } + + if ($targetGroup === null || $targetGroup === '') { + $targetGroup = (string)$dirGroupId; + } + + @chgrp($path, $targetGroup); + } + + private function generateTemporaryRoleName(): string + { + $base = strtolower($this->daUser->username()); + $base = preg_replace('/[^a-z0-9_]+/', '_', $base) ?? 'user'; + $base = trim($base, '_'); + if ($base === '') { + $base = 'user'; + } + + for ($i = 0; $i < 5; $i++) { + $suffix = bin2hex(random_bytes(6)); + $maxBaseLen = NamePolicy::MAX_IDENTIFIER_LEN - strlen(self::TMP_ROLE_PREFIX) - 1 - strlen($suffix); + if ($maxBaseLen < 1) { + $maxBaseLen = 1; + } + $safeBase = substr($base, 0, $maxBaseLen); + $role = self::TMP_ROLE_PREFIX . $safeBase . '_' . $suffix; + if (!$this->pg->roleExists($role)) { + return $role; + } + } + + throw new RuntimeException('Nie udało się wygenerować unikalnej tymczasowej roli dla Adminera.'); + } + + private function generateTicketId(): string + { + return rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); + } + + private function generateSecret(int $len): string + { + $value = rtrim(strtr(base64_encode(random_bytes(48)), '+/', 'AZ'), '='); + if (strlen($value) >= $len) { + return substr($value, 0, $len); + } + return str_pad($value, $len, 'x'); + } + + private function buildUserBinding(): string + { + $ua = Http::server('HTTP_USER_AGENT'); + if ($ua === '') { + $raw = getenv('HTTP_USER_AGENT'); + if ($raw !== false) { + $ua = trim((string)$raw); + } + } + return hash('sha256', $ua); + } + + private function buildAdminerBaseUrl(): string + { + $path = $this->settings->adminerUrlPath(); + $customBase = $this->settings->adminerPublicBaseUrl(); + if ($customBase !== '') { + return $customBase . $path; + } + + $host = trim(Http::server('HTTP_HOST')); + if ($host === '') { + $host = trim(Http::server('SERVER_NAME')); + } + if ($host === '') { + return $path; + } + + $host = preg_replace('/:\d+$/', '', $host) ?? $host; + $scheme = $this->detectRequestScheme(); + if ($scheme !== '') { + return $scheme . '://' . $host . $path; + } + + // Fallback bezpieczny: odziedzicz protokół aktualnej strony (unika mixed content). + return '//' . $host . $path; + } + + private function detectRequestScheme(): string + { + $forwardedProto = strtolower(trim(Http::server('HTTP_X_FORWARDED_PROTO'))); + if ($forwardedProto === 'https' || $forwardedProto === 'http') { + return $forwardedProto; + } + + $requestScheme = strtolower(trim(Http::server('REQUEST_SCHEME'))); + if ($requestScheme === 'https' || $requestScheme === 'http') { + return $requestScheme; + } + + $frontEndHttps = strtolower(trim(Http::server('HTTP_FRONT_END_HTTPS'))); + if ($frontEndHttps === 'on' || $frontEndHttps === '1' || $frontEndHttps === 'https') { + return 'https'; + } + + $https = strtolower(trim(Http::server('HTTPS'))); + if ($https === 'on' || $https === '1' || $https === 'https' || $https === 'true') { + return 'https'; + } + + $serverPort = trim(Http::server('SERVER_PORT')); + if ($serverPort === '443') { + return 'https'; + } + if ($serverPort === '80') { + return 'http'; + } + + return ''; + } +} diff --git a/exec/lib/AppContext.php b/exec/lib/AppContext.php new file mode 100644 index 0000000..87e3bda --- /dev/null +++ b/exec/lib/AppContext.php @@ -0,0 +1,1208 @@ +daUser = $daUser; + $this->settings = $settings; + $this->pg = $pg; + $this->csrf = $csrf; + $this->lang = $lang; + } + + public function action(): string + { + return (string)PLUGIN_ACTION; + } + + public function baseUrl(): string + { + return '/CMD_PLUGINS/da-postgresql'; + } + + public function url(string $page, array $query = []): string + { + $path = $this->baseUrl() . '/' . ltrim($page, '/'); + if (!empty($query)) { + $path .= '?' . http_build_query($query); + } + return $path; + } + + public function isPost(): bool + { + return Http::isPost(); + } + + public function postString(string $key, string $default = ''): string + { + return Http::getString($_POST, $key, $default); + } + + /** + * @return array + */ + public function postArray(string $key): array + { + $raw = Http::getArray($_POST, $key); + $out = []; + foreach ($raw as $item) { + if (is_array($item)) { + continue; + } + $out[] = trim((string)$item); + } + return $out; + } + + public function queryString(string $key, string $default = ''): string + { + return Http::getString($_GET, $key, $default); + } + + /** + * @param array $vars + */ + public function t(string $key, array $vars = []): string + { + return $this->lang->t($key, $vars); + } + + public function formatException(Throwable $e): string + { + if ($e instanceof LocalizedException) { + return $this->t($e->key(), $e->vars()); + } + return $this->t($e->getMessage()); + } + + public function csrfField(string $intent): string + { + $issued = $this->csrf->issue($intent); + $ts = (string)$issued['ts']; + $sid = htmlspecialchars((string)($issued['sid'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $token = htmlspecialchars($issued['token'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . + '' . + ''; + } + + public function requireCsrf(string $intent): void + { + $ts = $this->postString('csrf_ts'); + $sid = $this->postString('csrf_sid'); + $token = $this->postString('csrf_token'); + if (!$this->csrf->validate($intent, $ts, $token, 3600, $sid)) { + $debug = sprintf( + '[%s] CSRF_FAIL intent=%s action=%s method=%s ts=%s sid=%s token_prefix=%s post_keys=%s', + date('c'), + $intent, + $this->action(), + Http::method(), + $ts, + $sid, + substr($token, 0, 12), + implode(',', array_keys($_POST)) + ); + @error_log($debug . "\n", 3, PLUGIN_ROOT . '/error.log'); + throw new RuntimeException('Nieprawidłowy lub wygasły token CSRF.'); + } + } + + /** + * @param string[] $okMessages + * @param string[] $errorMessages + */ + public function renderPage(string $title, string $body, array $okMessages = [], array $errorMessages = [], string $logs = ''): void + { + $alerts = ''; + foreach ($okMessages as $msg) { + $alerts .= '
    ' . self::e($msg) . '
    '; + } + foreach ($errorMessages as $msg) { + $alerts .= '
    ' . self::e($msg) . '
    '; + } + + $alerts = $this->lang->translateHtml($alerts); + $body = $this->lang->translateHtml($body); + $logs = $this->lang->translateHtml($logs); + + $safeTitle = self::e($this->t($title)); + $topActions = $this->renderTopActions(); + $langCode = $this->daUser->language(); + + header('Content-Type: text/html; charset=UTF-8'); + echo ''; + echo ''; + echo '' . $safeTitle . ''; + echo ''; + echo ''; + echo '
    '; + echo ''; + $headerClass = in_array($this->action(), ['index', 'upload_backup'], true) ? ' class="header-center"' : ''; + echo '

    ' . $safeTitle . '

    '; + echo '
    ' . $topActions . '
    '; + echo '
    ' . $alerts . $body . '
    '; + echo '
    ' . $logs . '
    '; + echo '
    '; + $jsConfig = 'window.DA_POSTGRESQL_BASE_URL=' . json_encode($this->baseUrl(), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_POSTGRESQL_INDEX_URL=' . json_encode($this->url('index.html'), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_POSTGRESQL_USER_URL=' . json_encode($this->url('user/index.html'), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_POSTGRESQL_UPLOAD_RAW_URL=' . json_encode($this->url('upload_backup.raw'), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_POSTGRESQL_I18N=' . json_encode($this->jsI18n(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';'; + echo ''; + echo ''; + echo ''; + } + + public function renderFatal(string $message, int $code = 400): void + { + http_response_code($code); + $body = '

    ' . self::e($this->t($message)) . '

    '; + $body .= '

    ' . self::e($this->t('Powrót')) . '

    '; + $this->renderPage('Błąd', $body, [], []); + } + + public static function e(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function renderTopActions(): string + { + $links = []; + + $links[] = '' . self::e($this->t('BAZY DANYCH')) . ''; + $links[] = '' . self::e($this->t('UŻYTKOWNICY BAZ DANYCH')) . ''; + if ($this->settings->enableUploadBackup()) { + $links[] = '' . self::e($this->t('BACKUPY BAZ DANYCH')) . ''; + } + if ($this->settings->enableAdminer()) { + $links[] = '
    ' . + $this->csrfField('open_adminer_submit') . + '' . + '
    '; + } + + return implode('', $links); + } + + private function styles(): string + { + $skin = $this->daUser->skin(); + $path = PLUGIN_ROOT . '/user/' . $skin . '.css'; + if (!is_file($path)) { + $path = PLUGIN_ROOT . '/user/enhanced.css'; + } + $css = is_file($path) ? file_get_contents($path) : ''; + if ($css === false) { + return ''; + } + return $css; + } + + /** + * @return array + */ + private function jsI18n(): array + { + $keys = [ + 'Pobieranie...', + 'Nieudana odpowiedź serwera ({code})', + 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})', + 'Serwer zwrócił pusty plik.', + 'Nie można pobrać pliku backupu.', + 'Pobierz plik backupu', + 'Brak plików .sql/.gz', + 'Brak katalogów', + 'Nie udało się wczytać katalogów.', + 'Podaj nazwę katalogu.', + 'Nie udało się utworzyć katalogu.', + 'Wysyłanie pliku...', + 'Nie udało się wysłać pliku backupu.', + 'Wybierz plik .sql/.gz/.sql.gz lub wskaż ścieżkę pliku na serwerze.', + 'Wskaż ścieżkę do pliku SQL lub SQL.GZ.', + 'Wybierz plik .sql/.gz/.sql.gz do przywrócenia.', + ]; + + $out = []; + foreach ($keys as $key) { + $out[$key] = $this->t($key); + } + return $out; + } + + private function scripts(): string + { + $js = <<<'JS' +(function () { + var __i18n = (typeof window.DA_POSTGRESQL_I18N === 'object' && window.DA_POSTGRESQL_I18N) ? window.DA_POSTGRESQL_I18N : {}; + function tr(key) { + return (__i18n && Object.prototype.hasOwnProperty.call(__i18n, key)) ? __i18n[key] : key; + } + function trf(key, vars) { + var out = tr(key); + if (!vars) { + return out; + } + for (var k in vars) { + if (!Object.prototype.hasOwnProperty.call(vars, k)) { + continue; + } + var val = String(vars[k]); + out = out.replace(new RegExp('\\{' + k + '\\}', 'g'), val); + } + return out; + } + + function bindPrivilegesGroups() { + function bindSingleGroup(group) { + var allCheckbox = group.querySelector('input[type="checkbox"][value="ALL"]'); + if (!allCheckbox) { + return; + } + + var checkboxes = group.querySelectorAll('input[type="checkbox"]'); + var others = []; + for (var ci = 0; ci < checkboxes.length; ci += 1) { + if (checkboxes[ci] !== allCheckbox) { + others.push(checkboxes[ci]); + } + } + + var setOthers = function (state) { + for (var oi = 0; oi < others.length; oi += 1) { + others[oi].checked = state; + } + }; + + var refreshAll = function () { + if (others.length === 0) { + return; + } + + var fullyChecked = true; + for (var oi = 0; oi < others.length; oi += 1) { + if (!others[oi].checked) { + fullyChecked = false; + break; + } + } + allCheckbox.checked = fullyChecked; + }; + + if (allCheckbox.checked) { + setOthers(true); + } else { + refreshAll(); + } + + allCheckbox.addEventListener('change', function () { + setOthers(allCheckbox.checked); + }); + + for (var oi = 0; oi < others.length; oi += 1) { + others[oi].addEventListener('change', function (event) { + if (!event.target.checked) { + allCheckbox.checked = false; + return; + } + refreshAll(); + }); + } + } + + var groups = document.querySelectorAll('[data-privileges-group]'); + for (var gi = 0; gi < groups.length; gi += 1) { + bindSingleGroup(groups[gi]); + } + } + + function generatePassword(length) { + var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*_-+='; + var out = ''; + var max = chars.length; + var useCrypto = typeof window.crypto !== 'undefined' && typeof window.crypto.getRandomValues === 'function'; + var rnd = new Uint32Array(length); + + if (useCrypto) { + window.crypto.getRandomValues(rnd); + for (var i = 0; i < length; i += 1) { + out += chars.charAt(rnd[i] % max); + } + return out; + } + + for (var j = 0; j < length; j += 1) { + out += chars.charAt(Math.floor(Math.random() * max)); + } + return out; + } + + function bindPasswordTools() { + var generateButtons = document.querySelectorAll('[data-generate-target]'); + for (var i = 0; i < generateButtons.length; i += 1) { + generateButtons[i].addEventListener('click', function () { + var inputId = this.getAttribute('data-generate-target'); + var input = document.getElementById(inputId); + if (!input) { + return; + } + input.value = generatePassword(24); + var ev; + if (typeof Event === 'function') { + ev = new Event('input', { bubbles: true }); + } else { + ev = document.createEvent('Event'); + ev.initEvent('input', true, true); + } + input.dispatchEvent(ev); + }); + } + + var toggleButtons = document.querySelectorAll('[data-toggle-target]'); + for (var j = 0; j < toggleButtons.length; j += 1) { + toggleButtons[j].addEventListener('click', function () { + var inputId = this.getAttribute('data-toggle-target'); + var input = document.getElementById(inputId); + if (!input) { + return; + } + input.type = (input.type === 'password') ? 'text' : 'password'; + }); + } + } + + function bindSelectAllColumns() { + var headers = document.querySelectorAll('[data-select-all-for]'); + for (var i = 0; i < headers.length; i += 1) { + headers[i].style.cursor = 'pointer'; + headers[i].addEventListener('click', function () { + var className = this.getAttribute('data-select-all-for'); + if (!className) { + return; + } + + var table = this.closest('table'); + var selector = 'input[type="checkbox"].' + className; + var boxes = table ? table.querySelectorAll(selector) : document.querySelectorAll(selector); + if (!boxes.length) { + return; + } + + var shouldCheck = false; + for (var bi = 0; bi < boxes.length; bi += 1) { + if (!boxes[bi].checked) { + shouldCheck = true; + break; + } + } + + for (var bj = 0; bj < boxes.length; bj += 1) { + boxes[bj].checked = shouldCheck; + } + }); + } + } + + function bindCreateDatabaseAdvancedMode() { + var details = document.getElementById('create-db-advanced'); + var modeField = document.getElementById('create-db-mode'); + if (!details || !modeField) { + return; + } + + var roleNew = document.getElementById('role-mode-new'); + var roleExisting = document.getElementById('role-mode-existing'); + var roleInput = document.getElementById('advanced-role-name'); + var passwordInput = document.getElementById('advanced-role-password'); + var existingSelect = document.getElementById('existing-role-name'); + + var sync = function () { + var isAdvanced = !!details.open; + modeField.value = isAdvanced ? 'advanced' : 'simple'; + + if (roleNew && roleExisting) { + roleNew.disabled = !isAdvanced; + roleExisting.disabled = !isAdvanced; + if (!isAdvanced) { + roleNew.checked = true; + } + } + + var useExisting = isAdvanced && roleExisting && roleExisting.checked; + + if (roleInput) { + roleInput.disabled = !isAdvanced || useExisting; + } + if (passwordInput) { + var needsPassword = isAdvanced && !useExisting; + passwordInput.disabled = !isAdvanced || useExisting; + passwordInput.required = needsPassword; + } + if (existingSelect) { + existingSelect.disabled = !isAdvanced || !useExisting; + existingSelect.required = isAdvanced && useExisting; + } + }; + + details.addEventListener('toggle', sync); + if (roleNew) { + roleNew.addEventListener('change', sync); + } + if (roleExisting) { + roleExisting.addEventListener('change', sync); + } + sync(); + } + + function bindRestoreModeControls() { + var radios = document.querySelectorAll('input[name="restore_mode_global"]'); + if (!radios.length) { + return; + } + + var targetWrap = document.getElementById('restore-mode-target-wrap'); + var modeFields = document.querySelectorAll('.js-restore-mode-field'); + var perRowTargets = document.querySelectorAll('[data-restore-targets]'); + var targetCols = document.querySelectorAll('[data-restore-target-col]'); + + function currentMode() { + var checked = document.querySelector('input[name="restore_mode_global"]:checked'); + if (!checked) { + return 'overwrite'; + } + return checked.value === 'new_db' ? 'new_db' : 'overwrite'; + } + + function sync() { + var mode = currentMode(); + if (targetWrap) { + targetWrap.style.display = (mode === 'new_db') ? '' : 'none'; + } + for (var i = 0; i < modeFields.length; i += 1) { + modeFields[i].value = mode; + } + for (var j = 0; j < perRowTargets.length; j += 1) { + var container = perRowTargets[j]; + if (mode === 'new_db') { + container.style.display = ''; + } else { + container.style.display = 'none'; + } + var inputs = container.querySelectorAll('input[type="radio"]'); + for (var k = 0; k < inputs.length; k += 1) { + inputs[k].disabled = (mode !== 'new_db'); + } + } + for (var c = 0; c < targetCols.length; c += 1) { + targetCols[c].style.display = (mode === 'new_db') ? '' : 'none'; + } + } + + for (var r = 0; r < radios.length; r += 1) { + radios[r].addEventListener('change', sync); + } + sync(); + } + + function bindJobOverlays() { + var openButtons = document.querySelectorAll('[data-overlay-open]'); + var closeButtons = document.querySelectorAll('[data-overlay-close]'); + + function replaceOverlayJobInUrl(jobId) { + if (typeof window.URL !== 'function') { + return; + } + try { + var url = new URL(window.location.href); + if (jobId) { + url.searchParams.set('overlay_job', jobId); + } else { + url.searchParams.delete('overlay_job'); + } + window.history.replaceState({}, '', url.toString()); + } catch (e) { + } + } + + function hasAnyOpen() { + return document.querySelectorAll('.job-overlay.open').length > 0; + } + + function setOpen(id, state, jobId) { + var el = document.getElementById(id); + if (!el) { + return; + } + + if (state) { + el.classList.add('open'); + el.setAttribute('aria-hidden', 'false'); + document.body.classList.add('overlay-open'); + replaceOverlayJobInUrl(jobId || el.getAttribute('data-overlay-job-id') || ''); + } else { + el.classList.remove('open'); + el.setAttribute('aria-hidden', 'true'); + if (!hasAnyOpen()) { + document.body.classList.remove('overlay-open'); + replaceOverlayJobInUrl(''); + } + } + } + + for (var i = 0; i < openButtons.length; i += 1) { + openButtons[i].addEventListener('click', function () { + var id = this.getAttribute('data-overlay-open'); + if (!id) { + return; + } + var jobId = this.getAttribute('data-overlay-job') || ''; + setOpen(id, true, jobId); + }); + } + + for (var j = 0; j < closeButtons.length; j += 1) { + closeButtons[j].addEventListener('click', function () { + var id = this.getAttribute('data-overlay-close'); + if (!id) { + return; + } + setOpen(id, false, ''); + }); + } + + if (hasAnyOpen()) { + document.body.classList.add('overlay-open'); + } + + document.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') { + return; + } + var overlays = document.querySelectorAll('.job-overlay.open'); + for (var oi = 0; oi < overlays.length; oi += 1) { + overlays[oi].classList.remove('open'); + overlays[oi].setAttribute('aria-hidden', 'true'); + } + document.body.classList.remove('overlay-open'); + replaceOverlayJobInUrl(''); + }); + } + + function bindBackupAutoRefresh() { + var cfg = document.getElementById('backup-auto-refresh'); + if (!cfg) { + return; + } + if (cfg.getAttribute('data-enabled') !== '1') { + return; + } + + var seconds = parseInt(cfg.getAttribute('data-seconds') || '3', 10); + if (!seconds || seconds < 1) { + seconds = 3; + } + var statusEl = document.getElementById('job-status'); + var statusUrl = cfg.getAttribute('data-status-url') || ''; + if (!statusEl || !statusUrl || typeof window.fetch !== 'function') { + return; + } + var busy = false; + + function refreshStatus() { + if (busy) { return; } + if (window.__backupDownloadInProgress === true) { return; } + if (document.querySelector('.job-overlay.open')) { return; } + busy = true; + fetch(statusUrl, { credentials: 'same-origin', cache: 'no-store' }) + .then(function (res) { + if (!res.ok) { return ''; } + return res.text(); + }) + .then(function (html) { + if (html) { + statusEl.innerHTML = html; + bindJobOverlays(); + bindLogCopyButtons(); + } + }) + .catch(function () {}) + .finally(function () { busy = false; }); + } + + window.setInterval(refreshStatus, seconds * 1000); + } + + function bindBackupSlotPicker() { + var radios = document.querySelectorAll('form.br-time-picker input[type="radio"][name="backup_slot"]'); + if (!radios.length) { + return; + } + for (var i = 0; i < radios.length; i += 1) { + if (radios[i].dataset.bound === '1') { continue; } + radios[i].dataset.bound = '1'; + radios[i].addEventListener('change', function () { + if (!this.checked) { return; } + var form = this.form; + if (!form) { return; } + if (typeof window.fetch !== 'function') { + form.submit(); + return; + } + var container = document.querySelector('.br-restore-picker'); + if (!container) { + form.submit(); + return; + } + var params = new URLSearchParams(new FormData(form)); + params.set('action', 'local_picker'); + var url = window.location.pathname + '?' + params.toString(); + fetch(url, { credentials: 'same-origin', cache: 'no-store' }) + .then(function (res) { + if (!res.ok) { return ''; } + return res.text(); + }) + .then(function (html) { + if (!html) { return; } + container.innerHTML = html; + bindRestoreModeControls(); + bindBackupSlotPicker(); + }) + .catch(function () { + form.submit(); + }); + }); + } + } + + function bindUserBackupSlotPicker() { + var radios = document.querySelectorAll('form.br-user-time-picker input[type="radio"][name="user_slot"]'); + if (!radios.length) { + return; + } + for (var i = 0; i < radios.length; i += 1) { + if (radios[i].dataset.bound === '1') { continue; } + radios[i].dataset.bound = '1'; + radios[i].addEventListener('change', function () { + if (!this.checked) { return; } + var form = this.form; + if (!form) { return; } + if (typeof window.fetch !== 'function') { + form.submit(); + return; + } + var container = document.querySelector('.br-user-backup-picker'); + if (!container) { + form.submit(); + return; + } + var params = new URLSearchParams(new FormData(form)); + params.set('action', 'user_picker'); + var url = window.location.pathname + '?' + params.toString(); + fetch(url, { credentials: 'same-origin', cache: 'no-store' }) + .then(function (res) { + if (!res.ok) { return ''; } + return res.text(); + }) + .then(function (html) { + if (!html) { return; } + container.innerHTML = html; + bindUserBackupSlotPicker(); + bindBackupDownloadForms(); + bindJobOverlays(); + bindLogCopyButtons(); + }) + .catch(function () { + form.submit(); + }); + }); + } + } + + function bindLogCopyButtons() { + var buttons = document.querySelectorAll('[data-log-copy]'); + if (!buttons.length) { + return; + } + + function copyText(text, onDone) { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard.writeText(text).then(function () { + if (typeof onDone === 'function') { onDone(true); } + }).catch(function () { + if (typeof onDone === 'function') { onDone(false); } + }); + return; + } + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', 'readonly'); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + if (typeof onDone === 'function') { onDone(true); } + } catch (e) { + if (typeof onDone === 'function') { onDone(false); } + } + } + + for (var i = 0; i < buttons.length; i += 1) { + buttons[i].addEventListener('click', function () { + var targetId = this.getAttribute('data-log-copy'); + var target = targetId ? document.getElementById(targetId) : null; + if (!target) { + return; + } + var text = target.textContent || ''; + var btn = this; + var original = btn.textContent; + copyText(text, function (ok) { + if (ok) { + btn.textContent = tr('Skopiowano'); + window.setTimeout(function () { + btn.textContent = original || tr('Kopiuj'); + }, 1200); + } + }); + }); + } + } + + function bindDropzones() { + var zones = document.querySelectorAll('[data-dropzone]'); + if (!zones.length) { + return; + } + + function formatBytes(bytes) { + if (!bytes || bytes <= 0) { return '0 B'; } + var k = 1024; + var sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = Math.floor(Math.log(bytes) / Math.log(k)); + var value = bytes / Math.pow(k, i); + return value.toFixed(value >= 10 || i === 0 ? 0 : 1) + ' ' + sizes[i]; + } + + zones.forEach(function (zone) { + var inputId = zone.getAttribute('data-input'); + if (!inputId) { return; } + var input = document.getElementById(inputId); + if (!input) { return; } + + var pathInputId = zone.getAttribute('data-path-input'); + var pathInput = pathInputId ? document.getElementById(pathInputId) : null; + var stagedInputId = zone.getAttribute('data-staged-input'); + var stagedInput = stagedInputId ? document.getElementById(stagedInputId) : null; + var stagedNameInputId = zone.getAttribute('data-staged-name-input'); + var stagedNameInput = stagedNameInputId ? document.getElementById(stagedNameInputId) : null; + var nameEl = zone.querySelector('.dropzone-name'); + var sizeEl = zone.querySelector('.dropzone-size'); + var clearBtn = zone.querySelector('.dropzone-clear'); + + function clearStagedPath() { + if (stagedInput) { + stagedInput.value = ''; + try { + stagedInput.dispatchEvent(new Event('input', { bubbles: true })); + stagedInput.dispatchEvent(new Event('change', { bubbles: true })); + } catch (e) { + } + } + if (stagedNameInput) { + stagedNameInput.value = ''; + try { + stagedNameInput.dispatchEvent(new Event('input', { bubbles: true })); + stagedNameInput.dispatchEvent(new Event('change', { bubbles: true })); + } catch (e) { + } + } + } + + function updateView() { + var file = (input.files && input.files.length) ? input.files[0] : null; + if (file) { + zone.classList.add('is-filled'); + if (nameEl) { nameEl.textContent = file.name || '-'; } + if (sizeEl) { sizeEl.textContent = formatBytes(file.size || 0); } + if (stagedNameInput) { + stagedNameInput.value = String(file.name || ''); + try { + stagedNameInput.dispatchEvent(new Event('input', { bubbles: true })); + stagedNameInput.dispatchEvent(new Event('change', { bubbles: true })); + } catch (e) { + } + } + } else { + zone.classList.remove('is-filled'); + if (nameEl) { nameEl.textContent = '-'; } + if (sizeEl) { sizeEl.textContent = '-'; } + } + if (typeof window.__restoreFileSync === 'function') { + window.__restoreFileSync(); + } + } + + function setFile(file) { + if (!file) { return; } + try { + var dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + } catch (e) { + // fallback: rely on input change + } + if (pathInput) { + pathInput.value = ''; + } + clearStagedPath(); + updateView(); + } + + zone.addEventListener('click', function (ev) { + var hasFile = input.files && input.files.length > 0; + if (!hasFile) { + input.click(); + } + }); + + zone.addEventListener('keydown', function (ev) { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + var hasFile = input.files && input.files.length > 0; + if (!hasFile) { + input.click(); + } + } + }); + + zone.addEventListener('dragover', function (ev) { + ev.preventDefault(); + zone.classList.add('is-dragover'); + }); + zone.addEventListener('dragleave', function () { + zone.classList.remove('is-dragover'); + }); + zone.addEventListener('drop', function (ev) { + ev.preventDefault(); + zone.classList.remove('is-dragover'); + var files = ev.dataTransfer && ev.dataTransfer.files ? ev.dataTransfer.files : null; + if (!files || !files.length) { + return; + } + setFile(files[0]); + }); + + input.addEventListener('change', function () { + if (input.files && input.files.length && pathInput) { + pathInput.value = ''; + } + clearStagedPath(); + updateView(); + }); + + if (clearBtn) { + clearBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + input.value = ''; + clearStagedPath(); + updateView(); + }); + } + + updateView(); + }); + } + + function bindRestoreFileValidation() { + var form = document.querySelector('form[data-restore-file-form]'); + if (!form) { return; } + + var fileInput = form.querySelector('#source-file-upload'); + var pathInput = form.querySelector('#source-file-path'); + var stagedInput = form.querySelector('#uploaded-tmp-path'); + var originalNameInput = form.querySelector('#uploaded-original-name'); + var submitBtn = form.querySelector('button[type="submit"]'); + var modeInputs = form.querySelectorAll('input[name="source_mode"]'); + var localPanel = form.querySelector('[data-source-panel="local"]'); + var serverPanel = form.querySelector('[data-source-panel="server"]'); + + function selectedMode() { + if (!modeInputs || !modeInputs.length) { + return 'local'; + } + for (var i = 0; i < modeInputs.length; i += 1) { + if (modeInputs[i].checked) { + return String(modeInputs[i].value || 'local'); + } + } + return String(modeInputs[0].value || 'local'); + } + + function syncPanels() { + var mode = selectedMode(); + if (localPanel) { + localPanel.style.display = (mode === 'local') ? '' : 'none'; + } + if (serverPanel) { + serverPanel.style.display = (mode === 'server') ? '' : 'none'; + } + } + + function hasSelectedFile() { + return !!(fileInput && fileInput.files && fileInput.files.length > 0); + } + + function hasStagedPath() { + return !!(stagedInput && String(stagedInput.value || '').trim() !== ''); + } + + function hasServerPath() { + return !!(pathInput && String(pathInput.value || '').trim() !== ''); + } + + function sync() { + syncPanels(); + var mode = selectedMode(); + var ok = false; + if (mode === 'server') { + ok = hasServerPath(); + } else { + ok = hasSelectedFile() || hasStagedPath(); + } + if (submitBtn) { + submitBtn.disabled = !ok; + } + } + window.__restoreFileSync = sync; + + function dispatchInputEvents(target) { + if (!target) { + return; + } + try { + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + } catch (e) { + } + } + + form.addEventListener('submit', function (event) { + var hasPath = hasServerPath(); + var hasFile = hasSelectedFile(); + var hasStaged = hasStagedPath(); + var mode = selectedMode(); + + if (mode === 'server') { + if (!hasPath) { + event.preventDefault(); + window.alert(tr('Wskaż ścieżkę do pliku SQL lub SQL.GZ.')); + sync(); + return; + } + return; + } + + if (hasStaged && !hasFile) { + if (pathInput) { + pathInput.value = ''; + dispatchInputEvents(pathInput); + } + return; + } + + if (!hasFile && !hasStaged) { + event.preventDefault(); + window.alert(tr('Wybierz plik .sql/.gz/.sql.gz do przywrócenia.')); + sync(); + return; + } + + if (hasFile && originalNameInput && fileInput && fileInput.files && fileInput.files[0]) { + originalNameInput.value = String(fileInput.files[0].name || ''); + dispatchInputEvents(originalNameInput); + } + if (pathInput) { + pathInput.value = ''; + dispatchInputEvents(pathInput); + } + }); + + if (fileInput) { + fileInput.addEventListener('change', sync); + } + if (pathInput) { + pathInput.addEventListener('input', sync); + pathInput.addEventListener('change', sync); + } + if (stagedInput) { + stagedInput.addEventListener('input', sync); + stagedInput.addEventListener('change', sync); + } + if (originalNameInput) { + originalNameInput.addEventListener('input', sync); + originalNameInput.addEventListener('change', sync); + } + if (modeInputs && modeInputs.length) { + for (var mi = 0; mi < modeInputs.length; mi += 1) { + modeInputs[mi].addEventListener('change', function () { + if (selectedMode() === 'server' && stagedInput) { + stagedInput.value = ''; + dispatchInputEvents(stagedInput); + } + if (selectedMode() === 'server' && originalNameInput) { + originalNameInput.value = ''; + dispatchInputEvents(originalNameInput); + } + if (selectedMode() === 'local' && pathInput) { + pathInput.value = ''; + dispatchInputEvents(pathInput); + } + if (selectedMode() === 'server' && fileInput) { + fileInput.value = ''; + dispatchInputEvents(fileInput); + } + sync(); + }); + } + } + + sync(); + } + + function bindBackupDownloadForms() { + var forms = document.querySelectorAll('form.js-backup-download-form'); + if (!forms.length || typeof window.fetch !== 'function' || typeof window.URL === 'undefined') { + return; + } + + function parseFileName(contentDisposition) { + var value = String(contentDisposition || ''); + var utfMatch = value.match(/filename\*=UTF-8''([^;]+)/i); + if (utfMatch && utfMatch[1]) { + try { + return decodeURIComponent(utfMatch[1].trim().replace(/(^\"|\"$)/g, '')); + } catch (e) { + } + } + var basicMatch = value.match(/filename=\"?([^\";]+)\"?/i); + if (basicMatch && basicMatch[1]) { + return basicMatch[1].trim(); + } + return 'backup.sql'; + } + + for (var i = 0; i < forms.length; i += 1) { + forms[i].addEventListener('submit', function (event) { + event.preventDefault(); + var form = event.currentTarget; + if (!form) { + return; + } + + var submitButton = form.querySelector('button[type="submit"]'); + var originalLabel = submitButton ? submitButton.textContent : ''; + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = tr('Pobieranie...'); + } + + window.__backupDownloadInProgress = true; + var configuredUserUrl = (typeof window.DA_POSTGRESQL_USER_URL === 'string') ? window.DA_POSTGRESQL_USER_URL : ''; + var configuredIndexUrl = (typeof window.DA_POSTGRESQL_INDEX_URL === 'string') ? window.DA_POSTGRESQL_INDEX_URL : ''; + var configuredBaseUrl = (typeof window.DA_POSTGRESQL_BASE_URL === 'string') ? window.DA_POSTGRESQL_BASE_URL : ''; + var actionUrl = form.getAttribute('action') || configuredUserUrl || configuredIndexUrl || configuredBaseUrl || window.location.href; + var body = new FormData(form); + + window.fetch(actionUrl, { + method: 'POST', + body: body, + credentials: 'same-origin', + cache: 'no-store', + headers: { + 'Accept': 'application/octet-stream' + } + }).then(function (response) { + if (!response.ok) { + throw new Error(trf('Nieudana odpowiedź serwera ({code})', { code: response.status })); + } + + var disposition = String(response.headers.get('Content-Disposition') || ''); + var contentType = String(response.headers.get('Content-Type') || '').toLowerCase(); + if (disposition.toLowerCase().indexOf('attachment') === -1 && contentType.indexOf('text/html') !== -1) { + return response.text().then(function (text) { + var trimmed = String(text || '').replace(/\s+/g, ' ').trim(); + var tail = trimmed ? (': ' + trimmed.slice(0, 220)) : ''; + throw new Error(trf('Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})', { url: actionUrl }) + tail); + }); + } + + return response.blob().then(function (blob) { + if (!blob || blob.size === 0) { + throw new Error(tr('Serwer zwrócił pusty plik.')); + } + var fileName = parseFileName(disposition); + var blobUrl = window.URL.createObjectURL(blob); + var link = document.createElement('a'); + link.style.display = 'none'; + link.href = blobUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + window.setTimeout(function () { + window.URL.revokeObjectURL(blobUrl); + if (link.parentNode) { + link.parentNode.removeChild(link); + } + }, 1000); + }); + }).catch(function (err) { + var message = (err && err.message) ? err.message : tr('Nie można pobrać pliku backupu.'); + window.alert(message); + }).finally(function () { + window.__backupDownloadInProgress = false; + if (submitButton) { + submitButton.disabled = false; + submitButton.textContent = originalLabel || tr('Pobierz plik backupu'); + } + }); + }); + } + } +JS; + $js .= FilePicker::scripts(); + $js .= <<<'JS' + bindPrivilegesGroups(); + bindPasswordTools(); + bindSelectAllColumns(); + bindCreateDatabaseAdvancedMode(); + bindRestoreModeControls(); + bindJobOverlays(); + bindBackupAutoRefresh(); + bindBackupDownloadForms(); + bindBackupSlotPicker(); + bindUserBackupSlotPicker(); + bindLogCopyButtons(); + bindFilePicker(); + bindDropzones(); + bindRestoreFileValidation(); +})(); +JS; + return $js; + } +} diff --git a/exec/lib/BackupQueueService.php b/exec/lib/BackupQueueService.php new file mode 100644 index 0000000..34c3e6a --- /dev/null +++ b/exec/lib/BackupQueueService.php @@ -0,0 +1,1618 @@ +settings = $settings; + $this->daUser = $daUser; + $this->pluginRoot = defined('PLUGIN_ROOT') ? PLUGIN_ROOT : dirname(__DIR__, 2); + $this->dataDir = $this->pluginRoot . '/data'; + $this->jobsPendingDir = $this->dataDir . '/jobs/pending'; + $this->jobsProcessingDir = $this->dataDir . '/jobs/processing'; + $this->jobsDoneDir = $this->dataDir . '/jobs/done'; + $this->logsDir = $this->dataDir . '/logs'; + $this->lockDir = $this->dataDir . '/lock'; + $this->uploadsDir = $this->dataDir . '/uploads'; + } + + public function ensureInfrastructure(): void + { + $dirs = [ + $this->dataDir, + $this->dataDir . '/jobs', + $this->jobsPendingDir, + $this->jobsProcessingDir, + $this->jobsDoneDir, + $this->logsDir, + $this->lockDir, + $this->dataDir . '/pids', + $this->dataDir . '/cancel', + $this->uploadsDir, + $this->uploadsDir . '/' . $this->daUser->username(), + ]; + + foreach ($dirs as $dir) { + if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Nie można utworzyć katalogu: ' . $dir); + } + @chmod($dir, 0700); + } + } + + public function backupEnabled(): bool + { + return $this->settings->enableBackup(); + } + + public function uploadBackupEnabled(): bool + { + return $this->settings->enableUploadBackup(); + } + + public function userBackupRoot(): string + { + $root = $this->settings->userBaseBackupDir($this->daUser->username()); + if ($root === '') { + $root = '/home/' . $this->daUser->username() . '/postgres_backup'; + } + + return rtrim($root, '/'); + } + + public function scriptBackupRoot(): string + { + $root = $this->settings->hitmeBackupLocation(); + if ($root === '') { + $root = '/home/admin/postgres_backup'; + } + + return rtrim($root, '/'); + } + + public function userBackupDateDir(): string + { + $format = $this->settings->userBackupDateFormat(); + $dir = date($format); + if ($dir === '' || strpos($dir, '..') !== false || strpos($dir, '/') !== false || strpos($dir, '\\') !== false) { + return date('d.m.Y-H.i'); + } + + if (!preg_match('/[Hhis]/', $format)) { + $dir .= '-' . date('H.i.s'); + } + + $base = $dir; + $root = $this->userBackupRoot(); + $attempt = 0; + while (is_dir($root . '/' . $dir)) { + $attempt++; + $dir = $base . '-' . date('His') . ($attempt > 1 ? '-' . $attempt : ''); + } + + return $dir; + } + + public function queueBackupJob(string $dbName): string + { + if (!$this->backupEnabled()) { + throw new RuntimeException('Tworzenie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $this->assertDatabaseBelongsToUser($dbName); + + $jobId = $this->buildJobId('backup'); + $lockPath = $this->dbLockPath($dbName); + $compress = $this->settings->compressBackup() ? '1' : '0'; + $backupRoot = $this->userBackupRoot(); + $dateDir = $this->userBackupDateDir(); + + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $compress, $backupRoot, $dateDir): string { + $this->acquireDbLock($dbName, 'backup', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/backup-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'backup', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'START_TS' => (string)time(), + 'DATE_DIR' => $dateDir, + 'COMPRESS_BACKUP' => $compress, + 'USER_BASE_BACKUP_DIR' => $backupRoot, + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } + + public function queueRestoreFromLocal(string $relativeBackupPath, string $restoreMode = 'overwrite', string $targetDb = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $resolved = $this->resolveLocalBackupEntry($relativeBackupPath); + $sourceDb = $resolved['db_name']; + if ($sourceDb === '') { + throw new RuntimeException('Nie można ustalić docelowej bazy dla backupu lokalnego.'); + } + $restoreMode = strtolower(trim($restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + $dbName = $sourceDb; + if ($restoreMode === 'new_db') { + if ($targetDb === '') { + throw new RuntimeException('Wskaż docelową bazę danych.'); + } + $dbName = $targetDb; + } + $this->assertDatabaseBelongsToUser($dbName); + + $jobId = $this->buildJobId('restore'); + $lockPath = $this->dbLockPath($dbName); + + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $resolved, $restoreMode, $sourceDb): string { + $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'restore', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'SOURCE_DB_NAME' => $sourceDb, + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_FILE' => $resolved['path'], + 'SOURCE_KIND' => 'local', + 'START_TS' => (string)time(), + 'DATE_DIR' => $resolved['date'], + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } + + public function queueRestoreFromFilePath(string $dbName, string $filePath, string $restoreMode = 'overwrite', string $sourceDbName = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $this->assertDatabaseBelongsToUser($dbName); + + $filePath = trim($filePath); + if ($filePath === '') { + throw new RuntimeException('Wybierz plik .sql/.gz/.sql.gz lub wskaż ścieżkę pliku na serwerze.'); + } + + if (!is_file($filePath) || !is_readable($filePath)) { + throw new RuntimeException('Nie można odczytać pliku: ' . $filePath); + } + + $realPath = @realpath($filePath); + if ($realPath === false) { + throw new RuntimeException('Nie można odczytać pliku: ' . $filePath); + } + + $allowedRoots = [ + '/home/' . $this->daUser->username(), + $this->uploadsDir . '/' . $this->daUser->username(), + ]; + $inAllowedRoot = false; + foreach ($allowedRoots as $rootPath) { + $realRoot = @realpath($rootPath); + if ($realRoot === false) { + continue; + } + if ($realPath === $realRoot || strpos($realPath, rtrim($realRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) === 0) { + $inAllowedRoot = true; + break; + } + } + if (!$inAllowedRoot) { + throw new RuntimeException('Ścieżka pliku musi znajdować się w katalogu /home/' . $this->daUser->username() . ' lub pochodzić z uploadu formularza.'); + } + + if (!$this->isAllowedSqlFile($filePath)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $jobId = $this->buildJobId('restore'); + $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($filePath)) ?? 'restore.sql'; + $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); + if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { + throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); + } + @chmod($targetDir, 0700); + + $copiedFile = $targetDir . '/' . $jobId . '_' . $safeBase; + if (!@copy($filePath, $copiedFile)) { + throw new RuntimeException('Nie udało się skopiować pliku do kolejki restore.'); + } + @chmod($copiedFile, 0600); + + $restoreMode = strtolower(trim($restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + // Bezpieczeństwo: dla restore z pliku ignorujemy źródłową nazwę bazy. + // Import ma być wykonywany wyłącznie do DB wskazanej przez użytkownika. + $sourceDbName = ''; + + $lockPath = $this->dbLockPath($dbName); + + try { + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $copiedFile, $restoreMode, $sourceDbName): string { + $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'restore', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_DB_NAME' => $sourceDbName, + 'SOURCE_FILE' => $copiedFile, + 'SOURCE_KIND' => 'file', + 'START_TS' => (string)time(), + 'DATE_DIR' => date('Y-m-d'), + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } catch (Throwable $e) { + @unlink($copiedFile); + throw $e; + } + } + + public function stageRestoreUploadFromStream(string $originalName): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + + $originalName = basename(trim($originalName)); + if ($originalName === '') { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + if (!$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $targetFile = $this->buildStagedUploadPath($originalName, 'stream'); + + $in = false; + $candidates = (PHP_SAPI === 'cli') + ? ['php://stdin', 'php://input'] + : ['php://input', 'php://stdin']; + foreach ($candidates as $candidate) { + $stream = @fopen($candidate, 'rb'); + if (is_resource($stream)) { + $in = $stream; + break; + } + } + if (!is_resource($in)) { + throw new RuntimeException('Nie można odczytać przesyłanego pliku.'); + } + $out = @fopen($targetFile, 'wb'); + if (!is_resource($out)) { + @fclose($in); + throw new RuntimeException('Nie można zapisać przesyłanego pliku na serwerze.'); + } + + $written = @stream_copy_to_stream($in, $out); + @fclose($out); + @fclose($in); + if ($written === false || (int)$written <= 0) { + $len = (string)($_SERVER['CONTENT_LENGTH'] ?? (getenv('CONTENT_LENGTH') ?: '')); + @error_log(sprintf( + "[%s] UPLOAD_BLOB_STREAM_EMPTY user=%s name=%s content_length=%s target=%s\n", + date('c'), + $this->daUser->username(), + $safeBase, + $len, + $targetFile + ), 3, PLUGIN_ROOT . '/error.log'); + @unlink($targetFile); + throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); + } + + @chmod($targetFile, 0600); + return $targetFile; + } + + /** + * @param array $upload + */ + public function stageRestoreUploadFromPhpUpload(array $upload, string $nameHint = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + + $error = isset($upload['error']) ? (int)$upload['error'] : UPLOAD_ERR_NO_FILE; + if ($error !== UPLOAD_ERR_OK) { + if ($error === UPLOAD_ERR_NO_FILE) { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + throw new RuntimeException('Nie udało się przesłać pliku backupu (kod błędu: ' . $error . ').'); + } + + $tmpName = isset($upload['tmp_name']) ? trim((string)$upload['tmp_name']) : ''; + if ($tmpName === '' || !is_file($tmpName) || !is_readable($tmpName)) { + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); + } + + $originalName = isset($upload['name']) ? basename((string)$upload['name']) : ''; + if ($originalName === '') { + $originalName = basename(trim($nameHint)); + } + if ($originalName === '') { + $originalName = basename($tmpName); + } + if ($originalName === '' || !$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $targetFile = $this->buildStagedUploadPath($originalName, 'php'); + $moved = @move_uploaded_file($tmpName, $targetFile); + if (!$moved) { + if (!@copy($tmpName, $targetFile)) { + throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); + } + @unlink($tmpName); + } + + @chmod($targetFile, 0600); + clearstatcache(true, $targetFile); + $size = @filesize($targetFile); + if (!is_int($size) || $size <= 0) { + @unlink($targetFile); + throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); + } + + return $targetFile; + } + + public function stageRestoreUploadFromDirectAdminTemp(string $uploadReference, string $originalName = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + + $uploadReference = trim($uploadReference); + if ($uploadReference === '') { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + + $token = basename(str_replace('\\', '/', $uploadReference)); + if ($token === '') { + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); + } + + $sourceCandidates = []; + if (str_starts_with($uploadReference, '/') && is_file($uploadReference)) { + $sourceCandidates[] = $uploadReference; + } + $tmpDirs = [ + (string)(getenv('UPLOAD_TMP_DIR') ?: ''), + (string)(getenv('DA_UPLOAD_TMP_DIR') ?: ''), + '/home/tmp', + '/tmp', + '/var/tmp', + sys_get_temp_dir(), + ]; + foreach ($tmpDirs as $dir) { + $dir = trim((string)$dir); + if ($dir === '') { + continue; + } + $sourceCandidates[] = rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $token; + } + $sourceCandidates = array_values(array_unique($sourceCandidates)); + + $sourcePath = ''; + foreach ($sourceCandidates as $candidate) { + if (is_file($candidate) && is_readable($candidate)) { + $sourcePath = $candidate; + break; + } + } + if ($sourcePath === '') { + @error_log(sprintf( + "[%s] UPLOAD_DA_TMP_MISSING user=%s ref=%s token=%s candidates=%s\n", + date('c'), + $this->daUser->username(), + $uploadReference, + $token, + implode('|', $sourceCandidates) + ), 3, PLUGIN_ROOT . '/error.log'); + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu. Wybierz plik ponownie.'); + } + + $originalName = basename(trim($originalName)); + if ($originalName === '' || !$this->isAllowedSqlFile($originalName)) { + $originalName = $this->isAllowedSqlFile($token) ? $token : 'restore.sql'; + } + if (!$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $targetFile = $this->buildStagedUploadPath($originalName, 'da'); + if (!@copy($sourcePath, $targetFile)) { + throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); + } + @chmod($targetFile, 0600); + clearstatcache(true, $targetFile); + $size = @filesize($targetFile); + if (!is_int($size) || $size <= 0) { + @unlink($targetFile); + throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); + } + + @error_log(sprintf( + "[%s] UPLOAD_DA_TMP_STAGED user=%s ref=%s src=%s dst=%s size=%s\n", + date('c'), + $this->daUser->username(), + $uploadReference, + $sourcePath, + $targetFile, + (string)$size + ), 3, PLUGIN_ROOT . '/error.log'); + + return $targetFile; + } + + /** + * @param array $upload + */ + public function queueRestoreFromUploadedFile(string $dbName, array $upload, string $restoreMode = 'overwrite'): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $this->assertDatabaseBelongsToUser($dbName); + + $error = isset($upload['error']) ? (int)$upload['error'] : UPLOAD_ERR_NO_FILE; + if ($error !== UPLOAD_ERR_OK) { + if ($error === UPLOAD_ERR_NO_FILE) { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + throw new RuntimeException('Nie udało się przesłać pliku backupu (kod błędu: ' . $error . ').'); + } + + $tmpName = isset($upload['tmp_name']) ? trim((string)$upload['tmp_name']) : ''; + if ($tmpName === '' || !is_file($tmpName) || !is_readable($tmpName)) { + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); + } + + $originalName = isset($upload['name']) ? basename((string)$upload['name']) : ''; + if ($originalName === '') { + $originalName = 'restore.sql'; + } + if (!$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $jobId = $this->buildJobId('restore'); + $restoreMode = strtolower(trim($restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', $originalName) ?? 'restore.sql'; + $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); + if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { + throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); + } + @chmod($targetDir, 0700); + + $copiedFile = $targetDir . '/' . $jobId . '_' . $safeBase; + $moved = @move_uploaded_file($tmpName, $copiedFile); + if (!$moved) { + if (!@copy($tmpName, $copiedFile)) { + throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); + } + @unlink($tmpName); + } + @chmod($copiedFile, 0600); + + $lockPath = $this->dbLockPath($dbName); + + try { + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $copiedFile, $restoreMode): string { + $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'restore', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_FILE' => $copiedFile, + 'SOURCE_KIND' => 'file', + 'START_TS' => (string)time(), + 'DATE_DIR' => date('Y-m-d'), + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } catch (Throwable $e) { + @unlink($copiedFile); + throw $e; + } + } + + /** + * @return array> + */ + public function listLocalBackupsByDate(): array + { + $root = $this->scriptBackupRoot(); + if (!is_dir($root)) { + return []; + } + + $out = []; + $dateDirs = []; + $items = @scandir($root); + if ($items === false) { + return []; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $full = $root . '/' . $item; + if (is_dir($full)) { + $dateDirs[] = $full; + } + } + + if (empty($dateDirs)) { + $dateDirs[] = $root; + } + + foreach ($dateDirs as $dirPath) { + $dirName = basename($dirPath); + $files = @scandir($dirPath); + if ($files === false) { + continue; + } + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $fullPath = $dirPath . '/' . $file; + if (!is_file($fullPath) || !$this->isAllowedSqlFile($fullPath)) { + continue; + } + + $mtime = (int)@filemtime($fullPath); + $rawDateKey = $dirPath === $root ? date('Y-m-d', $mtime) : $dirName; + $dateKey = $this->normalizeDateKey($rawDateKey, $mtime); + $relative = ltrim(substr($fullPath, strlen(rtrim($root, '/'))), '/'); + + $sourceDb = $this->extractSourceDbFromBackupFile($file); + if ($sourceDb !== '' && strpos($sourceDb, $this->daUser->prefix()) !== 0) { + continue; + } + + $entry = [ + 'id' => $relative, + 'file' => $file, + 'path' => $fullPath, + 'date' => $dateKey, + 'time' => $mtime > 0 ? date('H:i:s', $mtime) : '', + 'size' => $this->humanFileSize((int)@filesize($fullPath)), + 'source_db' => $sourceDb, + 'mtime' => $mtime, + ]; + + if (!isset($out[$dateKey])) { + $out[$dateKey] = []; + } + $out[$dateKey][] = $entry; + } + } + + foreach ($out as $date => $entries) { + usort($entries, static fn(array $a, array $b): int => ($b['mtime'] <=> $a['mtime'])); + $out[$date] = $entries; + } + + uksort($out, static fn(string $a, string $b): int => strcmp($b, $a)); + return $out; + } + + /** + * @return array> + */ + public function listUserBackupsByDate(int $limit = 500): array + { + $this->ensureInfrastructure(); + + $root = $this->userBackupRoot(); + $out = []; + if (!is_dir($root)) { + return []; + } + + $rootReal = @realpath($root); + $jobs = $this->listJobsForCurrentUser($limit); + $jobsByTarget = []; + foreach ($jobs as $job) { + if (($job['type'] ?? '') !== 'backup') { + continue; + } + $jobId = (string)($job['job_id'] ?? ''); + if ($jobId === '') { + continue; + } + $targetFile = $this->resolveBackupTargetPathFromJob($job, $jobId); + if ($targetFile === '') { + continue; + } + $realTarget = @realpath($targetFile); + if ($realTarget === false) { + continue; + } + if ($rootReal !== false) { + if (strpos($realTarget, rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0 && $realTarget !== $rootReal) { + continue; + } + } + $jobsByTarget[$realTarget] = $job; + } + + $dateDirs = []; + $items = @scandir($root); + if (is_array($items)) { + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $full = $root . '/' . $item; + if (is_dir($full)) { + $dateDirs[] = $full; + } + } + } + if (empty($dateDirs)) { + $dateDirs[] = $root; + } + + $dirFormat = $this->settings->userBackupDateFormat(); + foreach ($dateDirs as $dirPath) { + $dirName = basename($dirPath); + $dirTs = 0; + if ($dirPath !== $root && $dirName !== '') { + $dt = @DateTime::createFromFormat($dirFormat, $dirName); + if ($dt instanceof DateTime && $dt->format($dirFormat) === $dirName) { + $dirTs = $dt->getTimestamp(); + } + } + + $files = @scandir($dirPath); + if ($files === false) { + continue; + } + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + if (preg_match('/-current\\.sql(\\.gz)?$/i', $file)) { + continue; + } + + $fullPath = $dirPath . '/' . $file; + if (!is_file($fullPath) || !$this->isAllowedSqlFile($fullPath)) { + continue; + } + + $realPath = @realpath($fullPath); + if ($realPath === false) { + continue; + } + if ($rootReal !== false) { + if (strpos($realPath, rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0 && $realPath !== $rootReal) { + continue; + } + } + + $mtime = (int)@filemtime($realPath); + $ts = $dirTs > 0 ? $dirTs : $mtime; + if ($ts <= 0) { + $ts = (int)time(); + } + $dateKey = date('Y-m-d', $ts); + $timeLabel = date('H:i:s', $ts); + + $sourceDb = $this->extractSourceDbFromBackupFile($file); + if ($sourceDb !== '' && strpos($sourceDb, $this->daUser->prefix()) !== 0) { + continue; + } + + $job = $jobsByTarget[$realPath] ?? null; + $jobId = is_array($job) ? (string)($job['job_id'] ?? '') : ''; + $status = is_array($job) ? (string)($job['status'] ?? '') : 'done_ok'; + $logExists = is_array($job) ? (bool)($job['log_exists'] ?? false) : false; + $dbName = is_array($job) && (string)($job['db_name'] ?? '') !== '' ? (string)$job['db_name'] : $sourceDb; + + $relative = ltrim(substr($realPath, strlen(rtrim($root, '/'))), '/'); + + $entry = [ + 'job_id' => $jobId, + 'db_name' => $dbName, + 'date' => $dateKey, + 'time' => $timeLabel, + 'mtime' => $ts, + 'size' => $this->humanFileSize((int)@filesize($realPath)), + 'status' => $status, + 'log_exists' => $logExists, + 'target_file' => $realPath, + 'has_file' => true, + 'path' => $realPath, + 'rel_path' => $relative, + ]; + + if (!isset($out[$dateKey])) { + $out[$dateKey] = []; + } + $out[$dateKey][] = $entry; + } + } + + foreach ($out as $date => $entries) { + usort($entries, static fn(array $a, array $b): int => ((int)($b['mtime'] ?? 0) <=> (int)($a['mtime'] ?? 0))); + $out[$date] = $entries; + } + + uksort($out, static fn(string $a, string $b): int => strcmp($b, $a)); + return $out; + + } + + public function resolveUserBackupFileForCurrentUser(string $relativePath): string + { + $relativePath = ltrim(trim($relativePath), '/'); + if ($relativePath === '' || strpos($relativePath, '..') !== false) { + throw new RuntimeException('Nieprawidłowa ścieżka pliku backupu.'); + } + + $root = $this->userBackupRoot(); + $candidate = $root . '/' . $relativePath; + $realRoot = @realpath($root); + $realPath = @realpath($candidate); + if ($realRoot === false || $realPath === false) { + throw new RuntimeException('Nie można zlokalizować pliku backupu.'); + } + if (!str_starts_with($realPath, rtrim($realRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)) { + throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); + } + if (!is_file($realPath) || !$this->isAllowedSqlFile($realPath)) { + throw new RuntimeException('Nie można odczytać wskazanego pliku backupu.'); + } + + return $realPath; + } + + public function deleteUserBackupFileForCurrentUser(string $relativePath): string + { + $path = $this->resolveUserBackupFileForCurrentUser($relativePath); + + $currentCandidate = ''; + if (preg_match('/^(.*)\\.sql(\\.gz)?$/i', $path, $m)) { + $currentCandidate = $m[1] . '-current.sql' . ($m[2] ?? ''); + } + + if (!@unlink($path)) { + throw new RuntimeException('Nie udało się usunąć pliku backupu: ' . $path); + } + if ($currentCandidate !== '' && is_file($currentCandidate)) { + @unlink($currentCandidate); + } + + $parentDir = dirname($path); + if (is_dir($parentDir)) { + $items = @scandir($parentDir) ?: []; + $onlyDots = true; + foreach ($items as $item) { + if ($item !== '.' && $item !== '..') { + $onlyDots = false; + break; + } + } + if ($onlyDots) { + @rmdir($parentDir); + } + } + + return $path; + } + + private function normalizeDateKey(string $raw, int $fallbackTs = 0): string + { + $raw = trim($raw); + if ($raw === '') { + return $fallbackTs > 0 ? date('Y-m-d', $fallbackTs) : ''; + } + + $formats = ['Y-m-d', 'd-m-Y', 'Y.m.d', 'd.m.Y']; + foreach ($formats as $fmt) { + $dt = @DateTime::createFromFormat($fmt, $raw); + if ($dt instanceof DateTime && $dt->format($fmt) === $raw) { + return $dt->format('Y-m-d'); + } + } + + if ($fallbackTs > 0) { + return date('Y-m-d', $fallbackTs); + } + + return $raw; + } + + /** + * @return array + */ + public function listJobsForCurrentUser(int $limit = 150): array + { + $this->ensureInfrastructure(); + + $candidates = []; + $this->collectJobFiles($this->jobsPendingDir, 'pending', $candidates); + $this->collectJobFiles($this->jobsProcessingDir, 'processing', $candidates); + $this->collectDoneJobFiles($this->jobsDoneDir, $candidates); + + $out = []; + foreach ($candidates as $candidate) { + $meta = $this->parseJobFile($candidate['path']); + if (($meta['DA_USER'] ?? '') !== $this->daUser->username()) { + $fallback = $this->inferJobMetaFromLog($candidate['job_id']); + if ($fallback === null) { + continue; + } + $meta = array_merge($fallback, $meta); + } + + $jobId = (string)($meta['JOB_ID'] ?? $candidate['job_id']); + if ($jobId === '') { + continue; + } + + $out[] = [ + 'job_id' => $jobId, + 'type' => (string)($meta['JOB_TYPE'] ?? 'unknown'), + 'db_name' => (string)($meta['DB_NAME'] ?? ''), + 'status' => $candidate['status'], + 'start_ts' => (int)($meta['START_TS'] ?? @filemtime($candidate['path']) ?: 0), + 'updated_ts' => (int)@filemtime($candidate['path']), + 'file' => $candidate['path'], + 'log_exists' => is_file($this->logsDir . '/' . $jobId . '.log'), + 'source_file' => (string)($meta['SOURCE_FILE'] ?? ''), + 'target_file' => (string)($meta['TARGET_FILE'] ?? ''), + 'date_dir' => (string)($meta['DATE_DIR'] ?? ''), + 'restore_mode' => (string)($meta['RESTORE_MODE'] ?? ''), + 'source_db_name' => (string)($meta['SOURCE_DB_NAME'] ?? ''), + ]; + } + + usort( + $out, + static function (array $a, array $b): int { + if ($a['start_ts'] === $b['start_ts']) { + return strcmp($b['job_id'], $a['job_id']); + } + return $b['start_ts'] <=> $a['start_ts']; + } + ); + + if ($limit > 0 && count($out) > $limit) { + return array_slice($out, 0, $limit); + } + + return $out; + } + + /** + * @return array{ + * job_id:string, + * type:string, + * db_name:string, + * status:string, + * start_ts:int, + * updated_ts:int, + * file:string, + * log_exists:bool, + * source_file:string, + * target_file:string, + * date_dir:string + * } + */ + public function getJobForCurrentUser(string $jobId): array + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); + } + + $jobs = $this->listJobsForCurrentUser(500); + foreach ($jobs as $job) { + if ($job['job_id'] === $jobId) { + return $job; + } + } + + throw new RuntimeException('Nie znaleziono zadania lub brak dostępu.'); + } + + public function resolveBackupTargetFileForCurrentUser(string $jobId): string + { + $job = $this->getJobForCurrentUser($jobId); + if ($job['type'] !== 'backup') { + throw new RuntimeException('Wskazane zadanie nie jest zadaniem backupu.'); + } + if ($job['status'] !== 'done_ok') { + throw new RuntimeException('Backup nie został zakończony powodzeniem.'); + } + + $path = $this->resolveBackupTargetPathFromJob($job, $jobId); + if ($path === '') { + throw new RuntimeException('Brak ścieżki pliku backupu w metadanych zadania.'); + } + + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException('Plik backupu nie istnieje lub brak uprawnień odczytu (sprawdź uprawnienia katalogu i pliku backupu dla diradmin).'); + } + + $userRoot = $this->userBackupRoot(); + $globalRoot = rtrim($this->settings->hitmeBackupLocation(), '/'); + if (!$this->pathInDir($path, $userRoot) && !$this->pathInDir($path, $globalRoot)) { + throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); + } + + return $path; + } + + public function hasBackupTargetFileForCurrentUser(string $jobId): bool + { + try { + $path = $this->resolveBackupTargetFileForCurrentUser($jobId); + return $path !== '' && is_file($path) && is_readable($path); + } catch (Throwable $e) { + return false; + } + } + + public function deleteBackupTargetFileForCurrentUser(string $jobId): string + { + $path = $this->resolveBackupTargetFileForCurrentUser($jobId); + if (!is_file($path)) { + throw new RuntimeException('Plik backupu nie istnieje.'); + } + + $currentCandidate = ''; + if (preg_match('/^(.*)\\.sql(\\.gz)?$/i', $path, $m)) { + $currentCandidate = $m[1] . '-current.sql' . ($m[2] ?? ''); + } + + if (!@unlink($path)) { + throw new RuntimeException('Nie udało się usunąć pliku backupu: ' . $path); + } + if ($currentCandidate !== '' && is_file($currentCandidate)) { + @unlink($currentCandidate); + } + + $parentDir = dirname($path); + if (is_dir($parentDir)) { + $items = @scandir($parentDir) ?: []; + $onlyDots = true; + foreach ($items as $item) { + if ($item !== '.' && $item !== '..') { + $onlyDots = false; + break; + } + } + if ($onlyDots) { + @rmdir($parentDir); + } + } + + return $path; + } + + public function readLogForCurrentUser(string $jobId): string + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); + } + + $jobs = $this->listJobsForCurrentUser(); + $allowed = false; + foreach ($jobs as $job) { + if ($job['job_id'] === $jobId) { + $allowed = true; + break; + } + } + if (!$allowed) { + throw new RuntimeException('Brak dostępu do logu zadania.'); + } + + $path = $this->logsDir . '/' . $jobId . '.log'; + if (!is_file($path) || !is_readable($path)) { + return 'Brak logu dla wybranego zadania.'; + } + + $content = @file_get_contents($path); + if ($content === false || trim($content) === '') { + return 'Log jest pusty.'; + } + + return $content; + } + + public function deleteBackupLogForCurrentUser(string $jobId): string + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); + } + + $jobs = $this->listJobsForCurrentUser(); + $allowed = false; + foreach ($jobs as $job) { + if ($job['job_id'] === $jobId) { + $allowed = true; + break; + } + } + if (!$allowed) { + throw new RuntimeException('Brak dostępu do logu zadania.'); + } + + $path = $this->logsDir . '/' . $jobId . '.log'; + if (!is_file($path)) { + throw new RuntimeException('Log nie istnieje.'); + } + if (!@unlink($path)) { + throw new RuntimeException('Nie udało się usunąć logu zadania.'); + } + + return $path; + } + + public function isDbLocked(string $dbName): bool + { + return is_file($this->dbLockPath($dbName)); + } + + /** + * @return array{path:string,date:string,db_name:string} + */ + public function getLocalBackupEntry(string $relativePath): array + { + return $this->resolveLocalBackupEntry($relativePath); + } + + public function dbLockPath(string $dbName): string + { + $safe = preg_replace('/[^A-Za-z0-9_.-]+/', '_', $dbName) ?? 'db'; + return $this->lockDir . '/' . $safe . '.lock'; + } + + private function assertDatabaseBelongsToUser(string $dbName): void + { + if (strpos($dbName, $this->daUser->prefix()) !== 0) { + throw new RuntimeException('Baza danych nie należy do konta DirectAdmin: ' . $dbName); + } + } + + /** + * @return array{path:string,date:string,db_name:string} + */ + private function resolveLocalBackupEntry(string $relativePath): array + { + $relativePath = trim(str_replace('\\', '/', $relativePath)); + if ($relativePath === '') { + throw new RuntimeException('Nie wybrano pliku backupu z lokalnego katalogu.'); + } + if (strpos($relativePath, '..') !== false || str_starts_with($relativePath, '/')) { + throw new RuntimeException('Nieprawidłowa ścieżka backupu.'); + } + + $root = $this->scriptBackupRoot(); + $fullPath = $root . '/' . ltrim($relativePath, '/'); + if (!is_file($fullPath) || !is_readable($fullPath)) { + throw new RuntimeException('Nie można odczytać wskazanego pliku backupu.'); + } + if (!$this->isAllowedSqlFile($fullPath)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $realRoot = @realpath($root); + $realPath = @realpath($fullPath); + if ($realRoot === false || $realPath === false || !str_starts_with($realPath, rtrim($realRoot, '/') . '/')) { + throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); + } + + $dateDir = basename(dirname($realPath)); + if ($dateDir === '' || $dateDir === '.' || $dateDir === '..') { + $dateDir = date('Y-m-d'); + } + + $dbName = $this->extractSourceDbFromBackupFile(basename($realPath)); + if ($dbName === '') { + throw new RuntimeException('Nie można ustalić docelowej bazy dla backupu lokalnego.'); + } + + return ['path' => $realPath, 'date' => $dateDir, 'db_name' => $dbName]; + } + + public function detectSourceDatabaseFromBackupName(string $pathOrFile): string + { + $candidate = $this->extractSourceDbFromBackupFile(basename($pathOrFile)); + if ($candidate === '') { + return ''; + } + if (strpos($candidate, $this->daUser->prefix()) !== 0) { + return ''; + } + return $candidate; + } + + public function isAllowedSqlFile(string $path): bool + { + return (bool)preg_match('/\.(?:sql\.gz|sql|gz)$/i', basename($path)); + } + + private function extractSourceDbFromBackupFile(string $file): string + { + $name = preg_replace('/\.(?:sql\.gz|sql|gz)$/i', '', $file) ?? $file; + if ($name === '') { + return ''; + } + + $parts = explode('__', $name, 2); + $candidate = strtolower(trim($parts[0])); + if ($candidate === '' || !preg_match('/^[a-z0-9_]+$/', $candidate)) { + return ''; + } + + return $candidate; + } + + private function buildStagedUploadPath(string $originalName, string $prefix): string + { + $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($originalName)) ?? 'restore.sql'; + $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); + if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { + throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); + } + @chmod($targetDir, 0700); + + $token = date('YmdHis') . '-' . bin2hex(random_bytes(4)); + return $targetDir . '/' . $prefix . '-' . $token . '_' . $safeBase; + } + + private function buildJobId(string $type): string + { + return $type . '-' . date('YmdHis') . '-' . bin2hex(random_bytes(4)); + } + + private function withQueueLock(callable $callback) + { + $lockDir = $this->dataDir . '/jobs/queue.lock'; + $tries = 0; + while (!@mkdir($lockDir, 0700)) { + $tries++; + if (!is_dir($lockDir)) { + continue; + } + + $mtime = (int)@filemtime($lockDir); + if ($mtime > 0 && (time() - $mtime) > 120) { + @rmdir($lockDir); + } + + if ($tries >= 40) { + throw new RuntimeException('Nie można uzyskać blokady kolejki zadań. Spróbuj ponownie.'); + } + usleep(100000); + } + + try { + return $callback(); + } finally { + @rmdir($lockDir); + } + } + + private function acquireDbLock(string $dbName, string $jobType, string $jobId, string $lockPath): void + { + $handle = @fopen($lockPath, 'x'); + if ($handle === false) { + $existing = is_file($lockPath) ? trim((string)@file_get_contents($lockPath)) : ''; + $msg = 'Na bazie danych trwa już operacja. Zaczekaj na zakończenie poprzedniego zadania.'; + if ($existing !== '') { + $msg .= ' [' . $existing . ']'; + } + throw new RuntimeException($msg); + } + + $content = implode( + "\n", + [ + 'DB_NAME=' . $dbName, + 'JOB_TYPE=' . $jobType, + 'JOB_ID=' . $jobId, + 'DA_USER=' . $this->daUser->username(), + 'CREATED_AT=' . date('c'), + ] + ) . "\n"; + + fwrite($handle, $content); + fclose($handle); + @chmod($lockPath, 0600); + } + + private function releaseDbLock(string $lockPath): void + { + if (is_file($lockPath)) { + @unlink($lockPath); + } + } + + /** + * @param array $payload + */ + private function writeJobFile(string $path, array $payload): void + { + $lines = []; + foreach ($payload as $key => $value) { + $lines[] = $key . '=' . $this->envQuote($value); + } + $content = implode("\n", $lines) . "\n"; + + if (@file_put_contents($path, $content, LOCK_EX) === false) { + throw new RuntimeException('Nie udało się zapisać pliku zadania: ' . $path); + } + @chmod($path, 0600); + } + + private function envQuote(string $value): string + { + return "'" . str_replace("'", "'\\''", $value) . "'"; + } + + private function triggerWorker(): void + { + $script = $this->pluginRoot . '/scripts/worker.sh'; + if (!is_file($script)) { + return; + } + + $euid = null; + if (function_exists('posix_geteuid')) { + $euid = @posix_geteuid(); + } else { + $raw = @shell_exec('id -u 2>/dev/null'); + if (is_string($raw) && preg_match('/^[0-9]+$/', trim($raw))) { + $euid = (int)trim($raw); + } + } + + // Worker powinien wykonywać zadania z crona jako root, aby mieć dostęp + // do katalogów backupu i konfiguracji systemowej. + if ($euid !== null && $euid !== 0) { + return; + } + + $cmd = '/bin/bash ' . escapeshellarg($script) . ' >/dev/null 2>&1 &'; + @exec($cmd); + } + + /** + * @param array $target + */ + private function collectJobFiles(string $dir, string $status, array &$target): void + { + if (!is_dir($dir)) { + return; + } + + $files = glob($dir . '/*.env'); + if ($files === false) { + return; + } + + foreach ($files as $path) { + $base = basename($path); + $jobId = substr($base, 0, -4); + $target[] = ['path' => $path, 'status' => $status, 'job_id' => $jobId]; + } + } + + /** + * @param array $target + */ + private function collectDoneJobFiles(string $dir, array &$target): void + { + if (!is_dir($dir)) { + return; + } + + $patterns = ['*.ok' => 'done_ok', '*.fail' => 'done_fail', '*.cancel' => 'done_cancel']; + foreach ($patterns as $pattern => $status) { + $files = glob($dir . '/' . $pattern); + if ($files === false) { + continue; + } + foreach ($files as $path) { + $base = basename($path); + $jobId = preg_replace('/\.(ok|fail|cancel)$/', '', $base) ?? $base; + $target[] = ['path' => $path, 'status' => $status, 'job_id' => $jobId]; + } + } + } + + /** + * @return array + */ + private function parseJobFile(string $path): array + { + $out = []; + if (!is_readable($path)) { + return $out; + } + + $lines = @file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return $out; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + if (!preg_match('/^([A-Z0-9_]+)=(.*)$/', $line, $m)) { + continue; + } + + $key = $m[1]; + $value = trim((string)$m[2]); + if (strlen($value) >= 2 && $value[0] === '\'' && substr($value, -1) === '\'') { + $value = substr($value, 1, -1); + $value = str_replace("'\\''", "'", $value); + } elseif (strlen($value) >= 2 && $value[0] === '"' && substr($value, -1) === '"') { + $value = stripcslashes(substr($value, 1, -1)); + } + $out[$key] = $value; + } + + return $out; + } + + private function humanFileSize(int $bytes): string + { + if ($bytes <= 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $power = (int)floor(log($bytes, 1024)); + $power = min($power, count($units) - 1); + $value = $bytes / (1024 ** $power); + + return number_format($value, $power === 0 ? 0 : 2, '.', ' ') . ' ' . $units[$power]; + } + + private function pathInDir(string $path, string $dir): bool + { + $realPath = @realpath($path); + $realDir = @realpath($dir); + if ($realPath === false || $realDir === false) { + return false; + } + + $realDir = rtrim($realDir, '/'); + if ($realPath === $realDir) { + return true; + } + + return str_starts_with($realPath, $realDir . '/'); + } + + /** + * @param array $job + */ + private function resolveBackupTargetPathFromJob(array $job, string $jobId): string + { + $path = trim((string)($job['target_file'] ?? '')); + if ($path === '') { + $fallback = $this->inferJobMetaFromLog($jobId); + $path = $fallback !== null ? trim((string)($fallback['TARGET_FILE'] ?? '')) : ''; + } + + return $path; + } + + /** + * @return array|null + */ + private function inferJobMetaFromLog(string $jobId): ?array + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + return null; + } + + $logPath = $this->logsDir . '/' . $jobId . '.log'; + if (!is_file($logPath) || !is_readable($logPath)) { + return null; + } + + $lines = @file($logPath, FILE_IGNORE_NEW_LINES); + if ($lines === false || empty($lines)) { + return null; + } + + $type = 'unknown'; + $dbName = ''; + $sourceFile = ''; + $targetFile = ''; + $startTs = 0; + $restoreMode = ''; + $sourceDbName = ''; + + foreach ($lines as $lineRaw) { + $line = trim((string)$lineRaw); + if ($line === '') { + continue; + } + + if ($startTs === 0 && preg_match('/^\[([0-9:-]{19})\]/', $line, $mTs)) { + $parsed = strtotime($mTs[1]); + if ($parsed !== false) { + $startTs = (int)$parsed; + } + } + + if (stripos($line, 'Start backup job') !== false) { + $type = 'backup'; + } elseif (stripos($line, 'Start restore job') !== false) { + $type = 'restore'; + } + + if ($dbName === '' && preg_match('/Tworzenie backupu bazy\s+([a-z0-9_]+)/iu', $line, $mDb)) { + $dbName = strtolower($mDb[1]); + } + if ($dbName === '' && preg_match('/Przywracanie bazy\s+([a-z0-9_]+)/iu', $line, $mDb2)) { + $dbName = strtolower($mDb2[1]); + } + + if (preg_match('/Sanityzacja dumpa:\s*([a-z0-9_]+)\s*->\s*([a-z0-9_]+)/iu', $line, $mSan)) { + $sourceDbName = strtolower($mSan[1]); + $restoreMode = (strtolower($mSan[1]) === strtolower($mSan[2])) ? 'overwrite' : 'new_db'; + } + + if ($targetFile === '' && preg_match('/Backup zapisany:\s+(.+)$/u', $line, $mTarget)) { + $targetFile = trim($mTarget[1]); + } + if ($sourceFile === '' && preg_match('/Przywracanie bazy\s+[a-z0-9_]+\s+z pliku\s+(.+)$/iu', $line, $mSource)) { + $sourceFile = trim($mSource[1]); + } + } + + if ($type === 'unknown') { + if (str_starts_with($jobId, 'backup-') || str_starts_with($jobId, 'backup-backup-')) { + $type = 'backup'; + } elseif (str_starts_with($jobId, 'restore-') || str_starts_with($jobId, 'restore-restore-')) { + $type = 'restore'; + } + } + + if ($dbName === '' || strpos($dbName, $this->daUser->prefix()) !== 0) { + return null; + } + + return [ + 'DA_USER' => $this->daUser->username(), + 'JOB_TYPE' => $type, + 'DB_NAME' => $dbName, + 'SOURCE_FILE' => $sourceFile, + 'TARGET_FILE' => $targetFile, + 'START_TS' => $startTs > 0 ? (string)$startTs : '', + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_DB_NAME' => $sourceDbName, + ]; + } +} diff --git a/exec/lib/CsrfGuard.php b/exec/lib/CsrfGuard.php new file mode 100644 index 0000000..8cff56b --- /dev/null +++ b/exec/lib/CsrfGuard.php @@ -0,0 +1,70 @@ +secret = $secret; + $this->username = $username; + $this->sessionId = $sessionId !== '' ? $sessionId : 'no_session'; + } + + /** + * @return array{ts:int,token:string} + */ + public function issue(string $intent): array + { + $ts = time(); + $sid = $this->sessionId; + return [ + 'ts' => $ts, + 'sid' => $sid, + 'token' => $this->buildToken($intent, $ts, $sid), + ]; + } + + public function validate(string $intent, string $timestamp, string $token, int $ttl = 3600, string $sessionHint = ''): bool + { + if (!preg_match('/^[0-9]{1,20}$/', $timestamp)) { + return false; + } + + $ts = (int)$timestamp; + $now = time(); + if ($ts <= 0 || $ts > ($now + 30) || ($now - $ts) > $ttl) { + return false; + } + + $token = trim($token); + $sids = []; + + $hint = trim($sessionHint); + if ($hint !== '') { + $sids[] = $hint; + } + $sids[] = $this->sessionId; + $sids[] = 'no_session'; + $sids[] = ''; + $sids = array_values(array_unique($sids)); + + foreach ($sids as $sid) { + $expected = $this->buildToken($intent, $ts, $sid); + if (hash_equals($expected, $token)) { + return true; + } + } + + return false; + } + + private function buildToken(string $intent, int $timestamp, string $sid): string + { + $payload = implode('|', [$this->username, $sid !== '' ? $sid : 'no_session', $intent, (string)$timestamp]); + return hash_hmac('sha256', $payload, $this->secret); + } +} diff --git a/exec/lib/DirectAdminUser.php b/exec/lib/DirectAdminUser.php new file mode 100644 index 0000000..50e2ef7 --- /dev/null +++ b/exec/lib/DirectAdminUser.php @@ -0,0 +1,296 @@ + */ + private array $userConf; + + /** @var array */ + private array $packageConf; + + private Settings $settings; + + private function __construct(string $username, array $userConf, array $packageConf, Settings $settings) + { + $this->username = $username; + $this->prefix = $username . '_'; + $this->userConf = $userConf; + $this->packageConf = $packageConf; + $this->settings = $settings; + } + + public static function fromEnvironment(Settings $settings): self + { + $username = Http::server('USER'); + if ($username === '') { + $username = Http::server('USERNAME'); + } + + if ($username === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $username)) { + throw new RuntimeException('Nie można ustalić użytkownika DirectAdmin.'); + } + + $userConfPath = '/usr/local/directadmin/data/users/' . $username . '/user.conf'; + $userConf = self::loadSimpleConf($userConfPath); + $packageConf = self::loadPackageConf($username, $userConf); + + return new self($username, $userConf, $packageConf, $settings); + } + + public function username(): string + { + return $this->username; + } + + public function prefix(): string + { + return $this->prefix; + } + + public function language(): string + { + $lang = strtolower(trim((string)($this->userConf['language'] ?? $this->userConf['lang'] ?? 'en'))); + if ($lang === '' || strpos($lang, 'en') === 0) { + return 'en'; + } + if (strpos($lang, 'pl') === 0) { + return 'pl'; + } + return 'en'; + } + + public function skin(): string + { + $skin = strtolower(trim((string)($this->userConf['skin'] ?? 'enhanced'))); + if (strpos($skin, 'evolution') !== false) { + return 'evolution'; + } + if (strpos($skin, 'enhanced') !== false) { + return 'enhanced'; + } + return 'enhanced'; + } + + public function hasPluginAccess(): bool + { + return $this->isPluginEnabledByCustomItem() && $this->isDatabaseQuotaEnabled() && $this->isPluginAllowedByPluginRules(); + } + + public function pluginAccessErrorMessage(): string + { + if (!$this->isPluginEnabledByCustomItem()) { + return 'Plugin PostgreSQL jest wyłączony dla tego konta lub pakietu.'; + } + + if (!$this->isDatabaseQuotaEnabled()) { + return 'Plugin PostgreSQL jest wyłączony: limit baz danych PostgreSQL ustawiono na 0 i nie zaznaczono opcji Bez Ograniczeń.'; + } + + if (!$this->isPluginAllowedByPluginRules()) { + return 'Plugin PostgreSQL jest zablokowany przez reguły plugins_allow/plugins_deny.'; + } + + return 'Brak dostępu do pluginu PostgreSQL.'; + } + + public function maxDatabases(): int + { + if ($this->isUnlimitedDatabasesByCustomItem()) { + return 0; + } + + $rawValue = $this->rawDatabasesLimitValue(); + if ($rawValue === null || trim($rawValue) === '') { + $default = $this->settings->defaultDatabasesLimit(); + return $default < 0 ? 0 : $default; + } + + $raw = trim($rawValue); + $normalized = strtolower(trim($raw)); + if ($normalized === 'unlimited') { + return 0; + } + + if (!preg_match('/^[0-9]+$/', trim($raw))) { + $default = $this->settings->defaultDatabasesLimit(); + return $default < 0 ? 0 : $default; + } + + $limit = (int)trim($raw); + return $limit < 0 ? 0 : $limit; + } + + private function isPluginEnabledByCustomItem(): bool + { + $raw = $this->readScopedValue('postgresql_enabled'); + if ($raw === null) { + return false; + } + return Settings::toBool($raw, false); + } + + private function isUnlimitedDatabasesByCustomItem(): bool + { + $unlimitedFlags = [ + $this->readScopedValue('upostgresql'), + $this->readScopedValue('upostgresql_max_databases'), + $this->readScopedValue('postgresql_unlimited'), // backward compatibility + ]; + + foreach ($unlimitedFlags as $raw) { + if ($raw !== null && Settings::toBool($raw, false)) { + return true; + } + } + + $rawLimit = $this->rawDatabasesLimitValue(); + if ($rawLimit !== null && strtolower(trim($rawLimit)) === 'unlimited') { + return true; + } + + return false; + } + + private function isDatabaseQuotaEnabled(): bool + { + if ($this->isUnlimitedDatabasesByCustomItem()) { + return true; + } + + return $this->maxDatabases() > 0; + } + + private function rawDatabasesLimitValue(): ?string + { + $raw = $this->readScopedValue('postgresql_max_databases'); + if ($raw === null || trim($raw) === '') { + $raw = $this->readScopedValue('postgresql'); + } + return $raw; + } + + private function isPluginAllowedByPluginRules(): bool + { + $pluginId = 'da-postgresql'; + + foreach ([$this->packageConf, $this->userConf] as $conf) { + if (!empty($conf['plugins_allow'])) { + $allow = self::parsePluginsList($conf['plugins_allow']); + return in_array($pluginId, $allow, true); + } + } + + foreach ([$this->packageConf, $this->userConf] as $conf) { + if (!empty($conf['plugins_deny'])) { + $deny = self::parsePluginsList($conf['plugins_deny']); + return !in_array($pluginId, $deny, true); + } + } + + return true; + } + + private function readScopedValue(string $key): ?string + { + if (array_key_exists($key, $this->userConf)) { + return $this->userConf[$key]; + } + if (array_key_exists($key, $this->packageConf)) { + return $this->packageConf[$key]; + } + return null; + } + + /** + * @return array + */ + private static function loadSimpleConf(string $path): array + { + $data = []; + if (!is_readable($path)) { + return $data; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return $data; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + + if (!preg_match('/^([A-Za-z0-9_]+)=(.*)$/', $line, $m)) { + continue; + } + + $data[strtolower($m[1])] = trim((string)$m[2]); + } + + return $data; + } + + /** + * @param array $userConf + * @return array + */ + private static function loadPackageConf(string $username, array $userConf): array + { + $package = trim((string)($userConf['package'] ?? $userConf['user_package'] ?? '')); + if ($package === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $package)) { + return []; + } + + $candidates = []; + $owners = []; + foreach (['creator', 'owner', 'reseller', 'username'] as $k) { + if (!empty($userConf[$k]) && preg_match('/^[A-Za-z0-9._-]+$/', $userConf[$k])) { + $owners[] = $userConf[$k]; + } + } + $owners[] = $username; + $owners[] = 'admin'; + $owners = array_values(array_unique($owners)); + + foreach ($owners as $owner) { + $candidates[] = '/usr/local/directadmin/data/users/' . $owner . '/packages/' . $package; + $candidates[] = '/usr/local/directadmin/data/users/' . $owner . '/packages/' . $package . '.conf'; + } + + foreach ($candidates as $path) { + $conf = self::loadSimpleConf($path); + if (!empty($conf)) { + return $conf; + } + } + + return []; + } + + /** + * @return string[] + */ + private static function parsePluginsList(string $value): array + { + $value = trim($value); + if ($value === '') { + return []; + } + + $items = []; + foreach (explode(':', strtolower($value)) as $item) { + $item = trim($item); + if ($item !== '') { + $items[] = $item; + } + } + + return array_values(array_unique($items)); + } +} diff --git a/exec/lib/FilePicker.php b/exec/lib/FilePicker.php new file mode 100644 index 0000000..744d2dd --- /dev/null +++ b/exec/lib/FilePicker.php @@ -0,0 +1,536 @@ +t('Wybierz plik'); + $close = $ctx->t('Zamknij'); + $up = $ctx->t('W górę'); + $newDir = $ctx->t('Nowy katalog'); + $create = $ctx->t('Utwórz'); + $cancel = $ctx->t('Anuluj'); + $choose = $ctx->t('Wybierz'); + + $html = ''; + $html .= ''; + + return $html; + } + + public static function handleDirList(AppContext $ctx, BackupQueueService $queue, string $rootPath): bool + { + $action = strtolower($ctx->queryString('action')); + if ($action !== 'dir_list') { + return false; + } + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + + $base = self::normalizeBase($rootPath); + $mode = strtolower($ctx->queryString('mode', 'dir')) === 'file' ? 'file' : 'dir'; + $requested = trim($ctx->queryString('path', $base)); + if ($requested === '') { + $requested = $base; + } + if ($requested !== '' && $requested[0] !== '/') { + $requested = '/' . $requested; + } + + $selected = ''; + if ($mode === 'file' && $requested !== '') { + $realReq = @realpath($requested); + if ($realReq !== false && is_file($realReq)) { + $selected = $realReq; + $requested = dirname($realReq); + } + } + + [$resolved, $base, $ok] = self::resolvePickerPath($requested, $base); + if (!$ok) { + self::pickerOutput(['ok' => false, 'error' => 'Nieprawidłowa ścieżka.']); + exit; + } + + if ($base !== '/' && $selected !== '') { + if (strpos($selected, $base . '/') !== 0 && $selected !== $base) { + $selected = ''; + } + } + + $dirItems = []; + $fileItems = []; + $entries = @scandir($resolved); + if (is_array($entries)) { + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $full = $resolved . '/' . $entry; + if (is_dir($full)) { + $dirItems[] = $entry; + } elseif ($mode === 'file' && is_file($full) && $queue->isAllowedSqlFile($full)) { + $fileItems[] = $entry; + } + } + } + + natcasesort($dirItems); + natcasesort($fileItems); + $items = []; + foreach ($dirItems as $name) { + $items[] = ['name' => $name, 'type' => 'dir']; + } + foreach ($fileItems as $name) { + $items[] = ['name' => $name, 'type' => 'file']; + } + + self::pickerOutput([ + 'ok' => true, + 'path' => $resolved, + 'base' => $base, + 'items' => $items, + 'selected' => $selected, + ]); + exit; + } + + public static function handleDirCreate(AppContext $ctx, string $rootPath, string $daUser): bool + { + $action = strtolower($ctx->queryString('action')); + if ($action !== 'dir_create') { + return false; + } + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + + $base = self::normalizeBase($rootPath); + $parentReq = trim($ctx->queryString('path', $base)); + if ($parentReq !== '' && $parentReq[0] !== '/') { + $parentReq = '/' . $parentReq; + } + [$parent, $base, $ok] = self::resolvePickerPath($parentReq, $base); + $name = trim($ctx->queryString('name')); + if ($name === '' || $name === '.' || $name === '..' || strpos($name, '/') !== false || strpos($name, '\\') !== false) { + self::pickerOutput(['ok' => false, 'error' => 'invalid_name']); + exit; + } + if (!$ok || !is_dir($parent)) { + self::pickerOutput(['ok' => false, 'error' => 'invalid_parent']); + exit; + } + + $newPath = rtrim($parent, '/') . '/' . $name; + if ($base !== '/' && strpos($newPath, $base . '/') !== 0 && $newPath !== $base) { + self::pickerOutput(['ok' => false, 'error' => 'outside_base']); + exit; + } + if (file_exists($newPath)) { + self::pickerOutput(['ok' => false, 'error' => 'exists']); + exit; + } + if (!@mkdir($newPath, 0700, true)) { + self::pickerOutput(['ok' => false, 'error' => 'mkdir_failed']); + exit; + } + @chown($newPath, $daUser); + @chgrp($newPath, $daUser); + self::pickerOutput(['ok' => true, 'path' => $parent, 'base' => $base, 'created' => $newPath]); + exit; + } + + /** + * @param array $payload + */ + private static function pickerOutput(array $payload): void + { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + if ($json === false) { + $json = json_encode(['ok' => false, 'error' => 'json_encode_failed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + $marker = '__DA_POSTGRESQL_PICKER_JSON__'; + header('Content-Type: text/plain; charset=UTF-8'); + echo $marker . "\n" . $json . "\n" . $marker; + } + + private static function normalizeBase(string $base): string + { + $base = trim($base); + if ($base === '') { + return '/'; + } + if ($base[0] !== '/') { + $base = '/' . $base; + } + if ($base !== '/' && substr($base, -1) === '/') { + $base = rtrim($base, '/'); + } + return $base === '' ? '/' : $base; + } + + /** + * @return array{0:string,1:string,2:bool} + */ + private static function resolvePickerPath(string $requested, string $base): array + { + $base = self::normalizeBase($base); + $candidate = trim($requested); + if ($candidate === '') { + $candidate = $base; + } + if ($candidate[0] !== '/') { + $candidate = '/' . $candidate; + } + $resolved = @realpath($candidate); + if ($resolved === false || !is_dir($resolved)) { + $resolved = @realpath($base); + } + if ($resolved === false || !is_dir($resolved)) { + return [$base, $base, false]; + } + if ($base !== '/' && strpos($resolved, $base . '/') !== 0 && $resolved !== $base) { + $resolved = @realpath($base); + } + if ($resolved === false || !is_dir($resolved)) { + return [$base, $base, false]; + } + return [$resolved, $base, true]; + } +} diff --git a/exec/lib/Http.php b/exec/lib/Http.php new file mode 100644 index 0000000..386fcdd --- /dev/null +++ b/exec/lib/Http.php @@ -0,0 +1,128 @@ + */ + private array $map; + + /** + * @param array $map + */ + private function __construct(array $map) + { + $this->map = $map; + } + + public static function load(string $code): self + { + $code = strtolower(trim($code)); + if ($code !== 'pl' && $code !== 'en') { + $code = 'en'; + } + + $path = PLUGIN_ROOT . '/lang/' . $code . '.php'; + if (!is_file($path)) { + $path = PLUGIN_ROOT . '/lang/en.php'; + } + + $map = []; + if (is_file($path)) { + $data = require $path; + if (is_array($data)) { + $map = $data; + } + } + + return new self($map); + } + + /** + * @param array $vars + */ + public function t(string $key, array $vars = []): string + { + $text = $this->map[$key] ?? $key; + foreach ($vars as $var => $value) { + $text = str_replace('{' . $var . '}', (string)$value, $text); + } + return $text; + } + + public function translateHtml(string $html): string + { + if ($html === '' || empty($this->map)) { + return $html; + } + + $keys = array_keys($this->map); + usort($keys, static function (string $a, string $b): int { + return strlen($b) <=> strlen($a); + }); + + $values = []; + foreach ($keys as $key) { + $values[] = $this->map[$key]; + } + + return str_replace($keys, $values, $html); + } + + /** + * @return string[] + */ + public function keys(): array + { + return array_keys($this->map); + } +} diff --git a/exec/lib/LocalizedException.php b/exec/lib/LocalizedException.php new file mode 100644 index 0000000..ad68eaf --- /dev/null +++ b/exec/lib/LocalizedException.php @@ -0,0 +1,30 @@ + */ + private array $vars; + + /** + * @param array $vars + */ + public function __construct(string $key, array $vars = [], int $code = 0, ?Throwable $previous = null) + { + parent::__construct($key, $code, $previous); + $this->vars = $vars; + } + + public function key(): string + { + return $this->getMessage(); + } + + /** + * @return array + */ + public function vars(): array + { + return $this->vars; + } +} diff --git a/exec/lib/NamePolicy.php b/exec/lib/NamePolicy.php new file mode 100644 index 0000000..077e427 --- /dev/null +++ b/exec/lib/NamePolicy.php @@ -0,0 +1,229 @@ + + */ + public const PRIVILEGE_LABELS = [ + 'ALL' => 'Wszystkie uprawnienia', + 'CONNECT' => 'CONNECT', + 'CREATE' => 'CREATE (baza/schemat)', + 'TEMPORARY' => 'TEMPORARY', + 'SCHEMA_USAGE' => 'USAGE (schema public)', + 'TABLE_SELECT' => 'SELECT (tabele)', + 'TABLE_INSERT' => 'INSERT (tabele)', + 'TABLE_UPDATE' => 'UPDATE (tabele)', + 'TABLE_DELETE' => 'DELETE (tabele)', + 'TABLE_TRUNCATE' => 'TRUNCATE (tabele)', + 'TABLE_REFERENCES' => 'REFERENCES (tabele)', + 'TABLE_TRIGGER' => 'TRIGGER (tabele)', + 'SEQUENCE_USAGE' => 'USAGE (sekwencje)', + 'SEQUENCE_SELECT' => 'SELECT (sekwencje)', + 'SEQUENCE_UPDATE' => 'UPDATE (sekwencje)', + 'FUNCTION_EXECUTE' => 'EXECUTE (funkcje)', + ]; + + public static function defaultPrivileges(): array + { + return ['ALL']; + } + + public static function normalizeDatabaseName(string $input, string $prefix): string + { + return self::normalizeIdentifier($input, $prefix, 'Nazwa bazy'); + } + + public static function normalizeRoleName(string $input, string $prefix): string + { + return self::normalizeIdentifier($input, $prefix, 'Nazwa użytkownika'); + } + + public static function assertBelongsToUser(string $name, string $prefix, string $entityLabel = 'Obiekt'): void + { + if (strpos($name, $prefix) !== 0) { + throw new InvalidArgumentException($entityLabel . ' musi zaczynać się od prefiksu: ' . $prefix); + } + } + + public static function normalizeIdentifier(string $input, string $prefix, string $label): string + { + $value = strtolower(trim($input)); + if ($value === '') { + throw new InvalidArgumentException($label . ' jest wymagana.'); + } + + if (!preg_match('/^[a-z0-9_]+$/', $value)) { + throw new InvalidArgumentException($label . ' może zawierać wyłącznie znaki a-z, 0-9 oraz _.'); + } + + if (strpos($value, $prefix) !== 0) { + $value = $prefix . $value; + } + + if (strpos($value, $prefix) !== 0) { + throw new InvalidArgumentException($label . ' musi zaczynać się od prefiksu: ' . $prefix); + } + + if (strlen($value) > self::MAX_IDENTIFIER_LEN) { + throw new InvalidArgumentException($label . ' przekracza limit 63 znaków PostgreSQL.'); + } + + if ($value === $prefix) { + throw new InvalidArgumentException($label . ' nie może być równa samemu prefiksowi.'); + } + + return $value; + } + + /** + * @return string[] + */ + public static function parseHosts(string $raw): array + { + $raw = str_replace(["\r\n", "\r", ';'], ["\n", "\n", "\n"], $raw); + $raw = str_replace(',', "\n", $raw); + $parts = explode("\n", $raw); + + $hosts = []; + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + $hosts[] = $part; + } + + return array_values(array_unique($hosts)); + } + + public static function normalizeHost(string $input, bool $allowWildcard): string + { + $host = strtolower(trim($input)); + if ($host === '') { + throw new InvalidArgumentException('Pusty host.'); + } + + if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) { + return $host; + } + + if (self::isStrictIpv4($host)) { + return $host; + } + + throw new InvalidArgumentException('Nieobsługiwany format hosta: ' . $input); + } + + public static function normalizeRemoteIpv4Host(string $input): string + { + $host = trim($input); + if ($host === '') { + throw new InvalidArgumentException('Adres IPv4 jest wymagany.'); + } + + if (!self::isStrictIpv4($host)) { + throw new InvalidArgumentException('Dozwolone są tylko pojedyncze adresy IPv4, np. 203.0.113.10.'); + } + + return $host; + } + + public static function normalizeRemoteIpv4AccessPattern(string $input): string + { + $host = trim(strtolower($input)); + if ($host === '') { + throw new InvalidArgumentException('Adres IPv4 albo CIDR jest wymagany.'); + } + + if (self::isStrictIpv4($host)) { + return $host; + } + + if (preg_match('#^([^/]+)/([0-9]{1,2})$#', $host, $m)) { + $ip = $m[1]; + $prefix = (int)$m[2]; + + if (!self::isStrictIpv4($ip) || $prefix < 0 || $prefix > 32) { + throw new InvalidArgumentException('Nieprawidłowy CIDR IPv4: ' . $input); + } + + return self::normalizeIpv4Cidr($ip, $prefix); + } + + throw new InvalidArgumentException('Dozwolone są pojedyncze adresy IPv4 albo CIDR, np. 203.0.113.10 lub 203.0.113.0/24.'); + } + + public static function normalizeHostComment(string $input): string + { + $comment = trim(preg_replace('/[\r\n\t]+/', ' ', $input) ?? ''); + $comment = preg_replace('/\s+/', ' ', $comment) ?? ''; + $comment = trim($comment); + + if (strlen($comment) > self::MAX_HOST_COMMENT_LEN) { + throw new InvalidArgumentException('Komentarz hosta przekracza limit ' . self::MAX_HOST_COMMENT_LEN . ' znaków.'); + } + + return $comment; + } + + /** + * @param array $input + * @return string[] + */ + public static function sanitizePrivileges(array $input): array + { + $allowed = array_keys(self::PRIVILEGE_LABELS); + $result = []; + + foreach ($input as $priv) { + $priv = strtoupper(trim((string)$priv)); + if ($priv === '') { + continue; + } + if (!in_array($priv, $allowed, true)) { + continue; + } + $result[] = $priv; + } + + $result = array_values(array_unique($result)); + if (in_array('ALL', $result, true)) { + return ['ALL']; + } + + return $result; + } + + private static function isStrictIpv4(string $value): bool + { + if (!preg_match('/^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/', $value)) { + return false; + } + + return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + } + + private static function normalizeIpv4Cidr(string $ip, int $prefix): string + { + $long = ip2long($ip); + if ($long === false) { + throw new InvalidArgumentException('Nieprawidłowy adres IPv4: ' . $ip); + } + + $unsigned = (int)sprintf('%u', $long); + $mask = $prefix === 0 ? 0 : ((0xFFFFFFFF << (32 - $prefix)) & 0xFFFFFFFF); + $network = $unsigned & $mask; + $networkIp = long2ip($network); + if ($networkIp === false) { + throw new InvalidArgumentException('Nieprawidłowy CIDR IPv4: ' . $ip . '/' . $prefix); + } + + return $networkIp . '/' . $prefix; + } +} diff --git a/exec/lib/PostgresService.php b/exec/lib/PostgresService.php new file mode 100644 index 0000000..75a5d7d --- /dev/null +++ b/exec/lib/PostgresService.php @@ -0,0 +1,1065 @@ + */ + private array $connections = []; + + public function __construct(array $credentials, Settings $settings) + { + $database = $credentials['database'] ?? 'postgres'; + if ($database === '*' || trim((string)$database) === '') { + $database = 'postgres'; + } + + $this->credentials = [ + 'host' => (string)($credentials['host'] ?? 'localhost'), + 'port' => (int)($credentials['port'] ?? 5432), + 'database' => (string)$database, + 'user' => (string)($credentials['user'] ?? 'postgres'), + 'password' => (string)($credentials['password'] ?? ''), + ]; + $this->settings = $settings; + } + + public function ensureMetadataSchema(): void + { + $this->query('CREATE SCHEMA IF NOT EXISTS da_plugin'); + $this->query( + 'CREATE TABLE IF NOT EXISTS da_plugin.access_hosts ( + da_user text NOT NULL, + db_name text NOT NULL DEFAULT \'\', + role_name text NOT NULL, + host_pattern text NOT NULL, + note text NOT NULL DEFAULT \'\', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (role_name, db_name, host_pattern) + )' + ); + $this->query('ALTER TABLE da_plugin.access_hosts ADD COLUMN IF NOT EXISTS db_name text NOT NULL DEFAULT \'\''); + $this->query('ALTER TABLE da_plugin.access_hosts DROP CONSTRAINT IF EXISTS access_hosts_pkey'); + $this->query('ALTER TABLE da_plugin.access_hosts ADD CONSTRAINT access_hosts_pkey PRIMARY KEY (role_name, db_name, host_pattern)'); + $this->query('ALTER TABLE da_plugin.access_hosts ADD COLUMN IF NOT EXISTS note text NOT NULL DEFAULT \'\''); + $this->query('CREATE INDEX IF NOT EXISTS access_hosts_da_user_idx ON da_plugin.access_hosts (da_user)'); + $this->query('CREATE INDEX IF NOT EXISTS access_hosts_da_user_db_idx ON da_plugin.access_hosts (da_user, db_name)'); + + $this->query( + 'CREATE TABLE IF NOT EXISTS da_plugin.grant_profiles ( + db_name text NOT NULL, + role_name text NOT NULL, + privileges text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (db_name, role_name) + )' + ); + } + + /** + * @return array{host:string,port:int} + */ + public function connectionEndpoint(): array + { + return [ + 'host' => $this->credentials['host'], + 'port' => (int)$this->credentials['port'], + ]; + } + + public function serverPort(): int + { + $row = $this->fetchOne('SHOW port'); + $port = isset($row['port']) ? (int)$row['port'] : 0; + if ($port > 0) { + return $port; + } + + return (int)$this->credentials['port']; + } + + public function serverVersion(): string + { + $row = $this->fetchOne('SHOW server_version'); + if (isset($row['server_version'])) { + return trim((string)$row['server_version']); + } + + $row = $this->fetchOne('SELECT version() AS version'); + if (isset($row['version'])) { + return trim((string)$row['version']); + } + + return ''; + } + + public function countDatabasesForUser(string $daUser): int + { + $row = $this->fetchOne( + 'SELECT COUNT(*)::int AS cnt + FROM pg_database + WHERE datistemplate = false AND datname LIKE $1 ESCAPE \'\\\'', + [$this->likePattern($daUser . '_')] + ); + + return isset($row['cnt']) ? (int)$row['cnt'] : 0; + } + + /** + * @return array + */ + public function listDatabasesForUser(string $daUser, bool $includeSize = true): array + { + if ($includeSize) { + $rows = $this->fetchAll( + 'SELECT d.datname AS name, + pg_get_userbyid(d.datdba) AS owner, + pg_size_pretty(pg_database_size(d.datname)) AS size + FROM pg_database d + WHERE d.datistemplate = false AND d.datname LIKE $1 ESCAPE \'\\\' + ORDER BY d.datname', + [$this->likePattern($daUser . '_')] + ); + } else { + $rows = $this->fetchAll( + 'SELECT d.datname AS name, + pg_get_userbyid(d.datdba) AS owner, + \'\'::text AS size + FROM pg_database d + WHERE d.datistemplate = false AND d.datname LIKE $1 ESCAPE \'\\\' + ORDER BY d.datname', + [$this->likePattern($daUser . '_')] + ); + } + + $out = []; + foreach ($rows as $row) { + $out[] = [ + 'name' => (string)$row['name'], + 'owner' => (string)$row['owner'], + 'size' => (string)$row['size'], + ]; + } + + return $out; + } + + /** + * @return array + */ + public function listRolesForUser(string $daUser): array + { + $rows = $this->fetchAll( + 'SELECT rolname AS name, rolcanlogin AS can_login + FROM pg_roles + WHERE rolname LIKE $1 ESCAPE \'\\\' + ORDER BY rolname', + [$this->likePattern($daUser . '_')] + ); + + $out = []; + foreach ($rows as $row) { + $out[] = [ + 'name' => (string)$row['name'], + 'can_login' => ($row['can_login'] === 't' || $row['can_login'] === true), + ]; + } + + return $out; + } + + /** + * @return array> + */ + public function listDatabaseUsersMap(string $daUser): array + { + $rows = $this->fetchAll( + 'SELECT d.datname AS db_name, r.rolname AS role_name + FROM pg_database d + JOIN pg_roles r ON r.rolname LIKE $1 ESCAPE \'\\\' + WHERE d.datistemplate = false + AND d.datname LIKE $2 ESCAPE \'\\\' + AND ( + d.datdba = r.oid + OR EXISTS ( + SELECT 1 + FROM aclexplode(COALESCE(d.datacl, acldefault(\'d\', d.datdba))) AS acl + WHERE acl.grantee = r.oid + AND acl.privilege_type = \'CONNECT\' + ) + ) + ORDER BY d.datname, r.rolname', + [$this->likePattern($daUser . '_'), $this->likePattern($daUser . '_')] + ); + + $map = []; + foreach ($rows as $row) { + $db = (string)$row['db_name']; + $role = (string)$row['role_name']; + if (!isset($map[$db])) { + $map[$db] = []; + } + $map[$db][] = $role; + } + + return $map; + } + + /** + * @return array> + */ + public function listRoleDatabasesMap(string $daUser): array + { + $dbMap = $this->listDatabaseUsersMap($daUser); + $out = []; + foreach ($dbMap as $db => $roles) { + foreach ($roles as $role) { + if (!isset($out[$role])) { + $out[$role] = []; + } + $out[$role][] = $db; + } + } + + ksort($out); + return $out; + } + + /** + * @return string[] + */ + public function listDatabaseUsers(string $daUser, string $database): array + { + $rows = $this->fetchAll( + 'SELECT r.rolname AS role_name + FROM pg_roles r + JOIN pg_database d ON d.datname = $2 + WHERE r.rolname LIKE $1 ESCAPE \'\\\' + AND d.datistemplate = false + AND ( + d.datdba = r.oid + OR EXISTS ( + SELECT 1 + FROM aclexplode(COALESCE(d.datacl, acldefault(\'d\', d.datdba))) AS acl + WHERE acl.grantee = r.oid + AND acl.privilege_type = \'CONNECT\' + ) + ) + ORDER BY r.rolname', + [$this->likePattern($daUser . '_'), $database] + ); + + $out = []; + foreach ($rows as $row) { + $out[] = (string)$row['role_name']; + } + return $out; + } + + public function roleExists(string $role): bool + { + $row = $this->fetchOne('SELECT 1 AS ok FROM pg_roles WHERE rolname = $1', [$role]); + return !empty($row); + } + + public function databaseExists(string $dbName): bool + { + $row = $this->fetchOne('SELECT 1 AS ok FROM pg_database WHERE datname = $1', [$dbName]); + return !empty($row); + } + + public function getDatabaseOwner(string $dbName): ?string + { + $row = $this->fetchOne('SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = $1', [$dbName]); + if (empty($row['owner'])) { + return null; + } + return (string)$row['owner']; + } + + /** + * @return array{name:string,owner:string,size:string,encoding:string,collation:string} + */ + public function getDatabaseInfo(string $dbName): array + { + $row = $this->fetchOne( + 'SELECT d.datname AS name, + pg_get_userbyid(d.datdba) AS owner, + pg_size_pretty(pg_database_size(d.datname)) AS size, + pg_encoding_to_char(d.encoding) AS encoding, + d.datcollate AS collation + FROM pg_database d + WHERE d.datname = $1', + [$dbName] + ); + + if (empty($row)) { + throw new RuntimeException('Nie znaleziono bazy danych: ' . $dbName); + } + + return [ + 'name' => (string)($row['name'] ?? $dbName), + 'owner' => (string)($row['owner'] ?? ''), + 'size' => (string)($row['size'] ?? '0 B'), + 'encoding' => (string)($row['encoding'] ?? 'UTF8'), + 'collation' => (string)($row['collation'] ?? ''), + ]; + } + + /** + * @return array{tables:int,views:int,triggers:int,routines:int} + */ + public function getDatabaseObjectStats(string $dbName): array + { + $row = $this->fetchOne( + 'SELECT + (SELECT COUNT(*)::int FROM information_schema.tables WHERE table_schema = \'public\' AND table_type = \'BASE TABLE\') AS tables, + (SELECT COUNT(*)::int FROM information_schema.views WHERE table_schema = \'public\') AS views, + (SELECT COUNT(*)::int FROM information_schema.triggers WHERE trigger_schema = \'public\') AS triggers, + (SELECT COUNT(*)::int FROM information_schema.routines WHERE specific_schema = \'public\') AS routines', + [], + $dbName + ); + + return [ + 'tables' => (int)($row['tables'] ?? 0), + 'views' => (int)($row['views'] ?? 0), + 'triggers' => (int)($row['triggers'] ?? 0), + 'routines' => (int)($row['routines'] ?? 0), + ]; + } + + /** + * @return string[] + */ + public function roleOwnedDatabases(string $role, string $daUser): array + { + $rows = $this->fetchAll( + 'SELECT datname AS name + FROM pg_database + WHERE datistemplate = false + AND pg_get_userbyid(datdba) = $1 + AND datname LIKE $2 ESCAPE \'\\\' + ORDER BY datname', + [$role, $this->likePattern($daUser . '_')] + ); + + $out = []; + foreach ($rows as $row) { + $out[] = (string)$row['name']; + } + return $out; + } + + /** + * @return string[] + */ + public function roleAssignedDatabases(string $role, string $daUser): array + { + $rows = $this->fetchAll( + 'SELECT datname AS name + FROM pg_database d + JOIN pg_roles r ON r.rolname = $1 + WHERE d.datistemplate = false + AND d.datname LIKE $2 ESCAPE \'\\\' + AND ( + d.datdba = r.oid + OR EXISTS ( + SELECT 1 + FROM aclexplode(COALESCE(d.datacl, acldefault(\'d\', d.datdba))) AS acl + WHERE acl.grantee = r.oid + AND acl.privilege_type = \'CONNECT\' + ) + ) + ORDER BY d.datname', + [$role, $this->likePattern($daUser . '_')] + ); + + $out = []; + foreach ($rows as $row) { + $out[] = (string)$row['name']; + } + return $out; + } + + /** + * @return array + */ + public function getRoleHostEntries(string $daUser, string $role, string $dbName = ''): array + { + $rows = $this->fetchAll( + 'SELECT db_name, host_pattern, note + FROM da_plugin.access_hosts + WHERE da_user = $1 AND role_name = $2 AND db_name = $3 + ORDER BY host_pattern', + [$daUser, $role, $dbName] + ); + + $out = []; + foreach ($rows as $row) { + $out[] = [ + 'db_name' => (string)($row['db_name'] ?? ''), + 'host' => (string)$row['host_pattern'], + 'note' => $this->sanitizeHostNote((string)($row['note'] ?? '')), + ]; + } + return $out; + } + + /** + * @return string[] + */ + public function getRoleHosts(string $daUser, string $role): array + { + $entries = $this->getRoleHostEntries($daUser, $role); + $out = []; + foreach ($entries as $entry) { + $out[] = $entry['host']; + } + return $out; + } + + /** + * @param string[] $hosts + */ + public function replaceRoleHosts(string $daUser, string $role, array $hosts): void + { + $entries = []; + foreach ($hosts as $host) { + $entries[] = ['host' => (string)$host, 'note' => '']; + } + $this->replaceRoleHostsWithNotes($daUser, $role, $entries); + } + + /** + * @param array $hostEntries + */ + public function replaceRoleHostsWithNotes(string $daUser, string $role, array $hostEntries, string $dbName = ''): void + { + $normalized = []; + foreach ($hostEntries as $entry) { + $host = trim((string)($entry['host'] ?? '')); + if ($host === '') { + continue; + } + $normalized[$host] = $this->sanitizeHostNote((string)($entry['note'] ?? '')); + } + + $this->query( + 'DELETE FROM da_plugin.access_hosts WHERE da_user = $1 AND role_name = $2 AND db_name = $3', + [$daUser, $role, $dbName] + ); + + foreach ($normalized as $host => $note) { + $this->query( + 'INSERT INTO da_plugin.access_hosts (da_user, db_name, role_name, host_pattern, note, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (role_name, db_name, host_pattern) + DO UPDATE SET da_user = EXCLUDED.da_user, note = EXCLUDED.note, updated_at = now()', + [$daUser, $dbName, $role, $host, $note] + ); + } + } + + public function deleteRoleHosts(string $daUser, string $role, ?string $dbName = null): void + { + if ($dbName === null) { + $this->query('DELETE FROM da_plugin.access_hosts WHERE da_user = $1 AND role_name = $2', [$daUser, $role]); + return; + } + + $this->query( + 'DELETE FROM da_plugin.access_hosts WHERE da_user = $1 AND role_name = $2 AND db_name = $3', + [$daUser, $role, $dbName] + ); + } + + public function createRole(string $role, string $password): void + { + $qRole = $this->quoteIdentifier($role); + $qPassword = $this->quoteLiteral($password); + $this->query("SET password_encryption = 'scram-sha-256'"); + $this->query( + 'CREATE ROLE ' . $qRole . ' LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOREPLICATION PASSWORD ' . $qPassword + ); + } + + public function changeRolePassword(string $role, string $password): void + { + $qRole = $this->quoteIdentifier($role); + $qPassword = $this->quoteLiteral($password); + $this->query("SET password_encryption = 'scram-sha-256'"); + $this->query('ALTER ROLE ' . $qRole . ' WITH LOGIN PASSWORD ' . $qPassword); + } + + public function createDatabase(string $dbName, string $owner): void + { + $qDb = $this->quoteIdentifier($dbName); + $qOwner = $this->quoteIdentifier($owner); + + $this->query('CREATE DATABASE ' . $qDb . ' WITH OWNER ' . $qOwner . " ENCODING 'UTF8' TEMPLATE template0"); + $this->enforceDatabaseIsolation($dbName); + } + + public function dropDatabase(string $dbName): void + { + $qDb = $this->quoteIdentifier($dbName); + $this->query( + 'SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()', + [$dbName] + ); + $this->query('DROP DATABASE IF EXISTS ' . $qDb); + } + + public function dropRole(string $role): void + { + $qRole = $this->quoteIdentifier($role); + $this->query('DROP ROLE IF EXISTS ' . $qRole); + } + + public function reassignOwnedObjects(string $dbName, string $fromRole, string $toRole): void + { + if ($fromRole === $toRole) { + return; + } + + $qFrom = $this->quoteIdentifier($fromRole); + $qTo = $this->quoteIdentifier($toRole); + $this->query('REASSIGN OWNED BY ' . $qFrom . ' TO ' . $qTo, [], $dbName); + $this->query('DROP OWNED BY ' . $qFrom, [], $dbName); + } + + /** + * @param string[] $privileges + */ + public function grantPrivileges(string $dbName, string $role, array $privileges): void + { + $privileges = array_values(array_unique($privileges)); + if (empty($privileges)) { + throw new RuntimeException('Brak uprawnień do nadania.'); + } + $this->enforceDatabaseIsolation($dbName); + + if (in_array('ALL', $privileges, true)) { + $this->grantAllPrivileges($dbName, $role); + $this->saveGrantProfile($dbName, $role, ['ALL']); + return; + } + + $this->revokePrivilegesInternal($dbName, $role, false); + + $qDb = $this->quoteIdentifier($dbName); + $qRole = $this->quoteIdentifier($role); + + $dbPrivsMap = [ + 'CONNECT' => 'CONNECT', + 'CREATE' => 'CREATE', + 'TEMPORARY' => 'TEMPORARY', + ]; + + $dbPrivs = []; + foreach ($dbPrivsMap as $key => $sqlPriv) { + if (in_array($key, $privileges, true)) { + $dbPrivs[] = $sqlPriv; + } + } + + if (!empty($dbPrivs)) { + $this->query('GRANT ' . implode(', ', $dbPrivs) . ' ON DATABASE ' . $qDb . ' TO ' . $qRole); + } + + $schemaPrivs = []; + if (in_array('SCHEMA_USAGE', $privileges, true)) { + $schemaPrivs[] = 'USAGE'; + } + if (in_array('CREATE', $privileges, true)) { + $schemaPrivs[] = 'CREATE'; + } + + $tablePrivs = []; + $tableMap = [ + 'TABLE_SELECT' => 'SELECT', + 'TABLE_INSERT' => 'INSERT', + 'TABLE_UPDATE' => 'UPDATE', + 'TABLE_DELETE' => 'DELETE', + 'TABLE_TRUNCATE' => 'TRUNCATE', + 'TABLE_REFERENCES' => 'REFERENCES', + 'TABLE_TRIGGER' => 'TRIGGER', + ]; + foreach ($tableMap as $key => $sqlPriv) { + if (in_array($key, $privileges, true)) { + $tablePrivs[] = $sqlPriv; + } + } + + $sequencePrivs = []; + $seqMap = [ + 'SEQUENCE_USAGE' => 'USAGE', + 'SEQUENCE_SELECT' => 'SELECT', + 'SEQUENCE_UPDATE' => 'UPDATE', + ]; + foreach ($seqMap as $key => $sqlPriv) { + if (in_array($key, $privileges, true)) { + $sequencePrivs[] = $sqlPriv; + } + } + + $functionExecute = in_array('FUNCTION_EXECUTE', $privileges, true); + + if (!empty($schemaPrivs)) { + $this->query('GRANT ' . implode(', ', $schemaPrivs) . ' ON SCHEMA public TO ' . $qRole, [], $dbName); + } + + if (!empty($tablePrivs)) { + $this->query('GRANT ' . implode(', ', $tablePrivs) . ' ON ALL TABLES IN SCHEMA public TO ' . $qRole, [], $dbName); + } + + if (!empty($sequencePrivs)) { + $this->query('GRANT ' . implode(', ', $sequencePrivs) . ' ON ALL SEQUENCES IN SCHEMA public TO ' . $qRole, [], $dbName); + } + + if ($functionExecute) { + $this->query('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ' . $qRole, [], $dbName); + } + + $owner = $this->getDatabaseOwner($dbName); + if ($owner !== null) { + $qOwner = $this->quoteIdentifier($owner); + if (!empty($tablePrivs)) { + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ' . implode(', ', $tablePrivs) . ' ON TABLES TO ' . $qRole, + [], + $dbName + ); + } + if (!empty($sequencePrivs)) { + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ' . implode(', ', $sequencePrivs) . ' ON SEQUENCES TO ' . $qRole, + [], + $dbName + ); + } + if ($functionExecute) { + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ' . $qRole, + [], + $dbName + ); + } + } + + sort($privileges); + $this->saveGrantProfile($dbName, $role, $privileges); + } + + public function revokePrivileges(string $dbName, string $role): void + { + $this->revokePrivilegesInternal($dbName, $role, true); + } + + public function getGrantProfile(string $dbName, string $role): array + { + $row = $this->fetchOne( + 'SELECT privileges FROM da_plugin.grant_profiles WHERE db_name = $1 AND role_name = $2', + [$dbName, $role] + ); + + if (empty($row['privileges'])) { + return []; + } + + $parts = array_filter(array_map('trim', explode(',', (string)$row['privileges']))); + $parts = array_map('strtoupper', $parts); + return array_values(array_unique($parts)); + } + + /** + * @return array{ok:bool,output:string} + */ + public function syncHbaRules(bool $restartPostgresql = false): array + { + $script = $this->settings->hbaSyncScript(); + if (!is_file($script)) { + return ['ok' => false, 'output' => 'Brak skryptu synchronizacji pg_hba: ' . $script]; + } + + $cmd = '/usr/bin/sudo -n ' . escapeshellarg($script); + if ($restartPostgresql) { + $cmd .= ' --restart-postgresql'; + } + $cmd .= ' 2>&1'; + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($restartPostgresql) { + $this->resetConnections(); + if ($exitCode === 0 && !$this->waitForConnectionAfterRestart()) { + $exitCode = 1; + $output[] = 'PostgreSQL został zrestartowany, ale plugin nie odzyskał połączenia w czasie oczekiwania.'; + } + } + + return [ + 'ok' => $exitCode === 0, + 'output' => trim(implode("\n", $output)), + ]; + } + + public function createTemporaryRole(string $role, string $password, int $expiresAt): void + { + $qRole = $this->quoteIdentifier($role); + $qPassword = $this->quoteLiteral($password); + $validUntil = gmdate('Y-m-d H:i:s+00', max($expiresAt, time() + 60)); + $qValidUntil = $this->quoteLiteral($validUntil); + + $this->query("SET password_encryption = 'scram-sha-256'"); + $this->query( + 'CREATE ROLE ' . $qRole . + ' LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOREPLICATION PASSWORD ' . $qPassword . + ' VALID UNTIL ' . $qValidUntil + ); + } + + public function grantTemporaryAdminerAccess(string $dbName, string $role): void + { + $qDb = $this->quoteIdentifier($dbName); + $qRole = $this->quoteIdentifier($role); + + $this->tryQuery('GRANT CONNECT, TEMPORARY ON DATABASE ' . $qDb . ' TO ' . $qRole); + $this->tryQuery('GRANT USAGE ON SCHEMA public TO ' . $qRole, [], $dbName); + $this->tryQuery( + 'GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public TO ' . $qRole, + [], + $dbName + ); + $this->tryQuery( + 'GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO ' . $qRole, + [], + $dbName + ); + $this->tryQuery('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ' . $qRole, [], $dbName); + } + + public function dropTemporaryRole(string $role): void + { + $qRole = $this->quoteIdentifier($role); + $dbRows = $this->fetchAll( + 'SELECT datname AS name + FROM pg_database + WHERE datistemplate = false + ORDER BY datname' + ); + + foreach ($dbRows as $dbRow) { + $dbName = (string)($dbRow['name'] ?? ''); + if ($dbName === '') { + continue; + } + $this->tryQuery('DROP OWNED BY ' . $qRole, [], $dbName); + } + + $this->tryQuery('DROP ROLE IF EXISTS ' . $qRole); + } + + public function cleanupExpiredAdminerRoles(string $rolePrefix = 'da_tmp_adminer_'): int + { + $rows = $this->fetchAll( + 'SELECT rolname AS name + FROM pg_roles + WHERE rolname LIKE $1 ESCAPE \'\\\' + AND rolvaliduntil IS NOT NULL + AND rolvaliduntil < now() + ORDER BY rolname', + [$this->likePattern($rolePrefix)] + ); + + if (empty($rows)) { + return 0; + } + + $removed = 0; + foreach ($rows as $row) { + $role = (string)($row['name'] ?? ''); + if ($role === '') { + continue; + } + + try { + $this->dropTemporaryRole($role); + if (!$this->roleExists($role)) { + $removed++; + } + } catch (Throwable $e) { + // Ignorujemy błędy cleanupu, aby nie blokować logowania. + } + } + + return $removed; + } + + private function grantAllPrivileges(string $dbName, string $role): void + { + $qDb = $this->quoteIdentifier($dbName); + $qRole = $this->quoteIdentifier($role); + $this->enforceDatabaseIsolation($dbName); + + $this->revokePrivilegesInternal($dbName, $role, false); + + $this->query('GRANT ALL PRIVILEGES ON DATABASE ' . $qDb . ' TO ' . $qRole); + $this->query('GRANT USAGE, CREATE ON SCHEMA public TO ' . $qRole, [], $dbName); + $this->query('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ' . $qRole, [], $dbName); + $this->query('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ' . $qRole, [], $dbName); + $this->query('GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ' . $qRole, [], $dbName); + + $owner = $this->getDatabaseOwner($dbName); + if ($owner !== null) { + $qOwner = $this->quoteIdentifier($owner); + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO ' . $qRole, + [], + $dbName + ); + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO ' . $qRole, + [], + $dbName + ); + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO ' . $qRole, + [], + $dbName + ); + } + } + + private function revokePrivilegesInternal(string $dbName, string $role, bool $removeProfile): void + { + $qDb = $this->quoteIdentifier($dbName); + $qRole = $this->quoteIdentifier($role); + + $this->tryQuery('REVOKE ALL PRIVILEGES ON DATABASE ' . $qDb . ' FROM ' . $qRole); + $this->tryQuery('REVOKE ALL PRIVILEGES ON SCHEMA public FROM ' . $qRole, [], $dbName); + $this->tryQuery('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM ' . $qRole, [], $dbName); + $this->tryQuery('REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM ' . $qRole, [], $dbName); + $this->tryQuery('REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public FROM ' . $qRole, [], $dbName); + + $owner = $this->getDatabaseOwner($dbName); + if ($owner !== null) { + $qOwner = $this->quoteIdentifier($owner); + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM ' . $qRole, + [], + $dbName + ); + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM ' . $qRole, + [], + $dbName + ); + $this->tryQuery( + 'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public REVOKE ALL PRIVILEGES ON FUNCTIONS FROM ' . $qRole, + [], + $dbName + ); + } + + if ($removeProfile) { + $this->query('DELETE FROM da_plugin.grant_profiles WHERE db_name = $1 AND role_name = $2', [$dbName, $role]); + } + } + + /** + * @param string[] $privileges + */ + private function saveGrantProfile(string $dbName, string $role, array $privileges): void + { + sort($privileges); + $serialized = implode(',', $privileges); + $this->query( + 'INSERT INTO da_plugin.grant_profiles (db_name, role_name, privileges, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (db_name, role_name) + DO UPDATE SET privileges = EXCLUDED.privileges, updated_at = now()', + [$dbName, $role, $serialized] + ); + } + + private function sanitizeHostNote(string $note): string + { + $note = preg_replace('/[\r\n\t]+/', ' ', $note) ?? ''; + $note = preg_replace('/\s+/', ' ', $note) ?? ''; + $note = trim($note); + + if (strlen($note) > NamePolicy::MAX_HOST_COMMENT_LEN) { + $note = substr($note, 0, NamePolicy::MAX_HOST_COMMENT_LEN); + } + + return $note; + } + + private function likePattern(string $prefix): string + { + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $prefix) . '%'; + } + + private function quoteIdentifier(string $name): string + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) { + throw new RuntimeException('Nieprawidłowy identyfikator PostgreSQL: ' . $name); + } + + if (strlen($name) > NamePolicy::MAX_IDENTIFIER_LEN) { + throw new RuntimeException('Identyfikator PostgreSQL przekracza 63 znaki: ' . $name); + } + + return '"' . str_replace('"', '""', $name) . '"'; + } + + private function enforceDatabaseIsolation(string $dbName): void + { + $qDb = $this->quoteIdentifier($dbName); + $this->query('REVOKE ALL PRIVILEGES ON DATABASE ' . $qDb . ' FROM PUBLIC'); + $this->tryQuery('REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC', [], $dbName); + } + + private function quoteLiteral(string $value, string $database = 'postgres'): string + { + $conn = $this->connect($database); + $quoted = pg_escape_literal($conn, $value); + if ($quoted === false) { + throw new RuntimeException('Nie udało się bezpiecznie zacytować wartości SQL.'); + } + + return $quoted; + } + + /** + * @param array $params + */ + private function query(string $sql, array $params = [], string $database = 'postgres') + { + $conn = $this->connect($database); + + if ($params === []) { + $res = @pg_query($conn, $sql); + } else { + $res = @pg_query_params($conn, $sql, $params); + } + + if ($res === false) { + throw new RuntimeException(trim((string)pg_last_error($conn)) . ' | SQL: ' . $sql); + } + + return $res; + } + + /** + * @param array $params + */ + private function tryQuery(string $sql, array $params = [], string $database = 'postgres'): void + { + try { + $this->query($sql, $params, $database); + } catch (Throwable $e) { + // celowo ignorujemy błędy pomocnicze (kompatybilność i idempotencja) + } + } + + /** + * @param array $params + * @return array> + */ + private function fetchAll(string $sql, array $params = [], string $database = 'postgres'): array + { + $res = $this->query($sql, $params, $database); + $rows = pg_fetch_all($res); + if ($rows === false) { + return []; + } + return $rows; + } + + /** + * @param array $params + * @return array + */ + private function fetchOne(string $sql, array $params = [], string $database = 'postgres'): array + { + $rows = $this->fetchAll($sql, $params, $database); + if (empty($rows)) { + return []; + } + return $rows[0]; + } + + /** + * @return resource + */ + private function connect(string $database = 'postgres') + { + if (isset($this->connections[$database])) { + if (@pg_connection_status($this->connections[$database]) === PGSQL_CONNECTION_OK) { + return $this->connections[$database]; + } + @pg_close($this->connections[$database]); + unset($this->connections[$database]); + } + + $targetDb = $database !== '' ? $database : $this->credentials['database']; + + $connString = sprintf( + "host=%s port=%d dbname=%s user=%s password=%s connect_timeout=5", + $this->escapeConnValue($this->credentials['host']), + $this->credentials['port'], + $this->escapeConnValue($targetDb), + $this->escapeConnValue($this->credentials['user']), + $this->escapeConnValue($this->credentials['password']) + ); + + $conn = @pg_connect($connString); + if ($conn === false) { + throw new RuntimeException('Nie udało się połączyć z PostgreSQL.'); + } + + $this->connections[$database] = $conn; + return $conn; + } + + private function resetConnections(): void + { + foreach ($this->connections as $conn) { + @pg_close($conn); + } + $this->connections = []; + } + + private function waitForConnectionAfterRestart(int $timeoutSeconds = 20): bool + { + $deadline = time() + max(1, $timeoutSeconds); + do { + try { + $this->resetConnections(); + $this->query('SELECT 1'); + return true; + } catch (Throwable $e) { + usleep(500000); + } + } while (time() < $deadline); + + $this->resetConnections(); + return false; + } + + private function escapeConnValue(string $value): string + { + return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; + } +} diff --git a/exec/lib/Settings.php b/exec/lib/Settings.php new file mode 100644 index 0000000..203bf64 --- /dev/null +++ b/exec/lib/Settings.php @@ -0,0 +1,365 @@ + */ + private array $values; + + private function __construct(array $values) + { + $this->values = $values; + } + + public static function load(string $path): self + { + $values = []; + if (is_readable($path)) { + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines !== false) { + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + + if (!preg_match('/^([A-Za-z0-9_]+)=(.*)$/', $line, $m)) { + continue; + } + + $values[$m[1]] = self::parseShValue((string)$m[2]); + } + } + } + + return new self($values); + } + + public static function loadPostgresqlCredentials(string $path): array + { + if (!is_readable($path)) { + throw new RuntimeException('Brak pliku z danymi PostgreSQL: ' . $path); + } + + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + throw new RuntimeException('Nie można odczytać pliku: ' . $path); + } + + $kv = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + + if (strpos($line, '=') !== false) { + [$k, $v] = array_map('trim', explode('=', $line, 2)); + if ($k !== '') { + $kv[strtoupper($k)] = self::parseShValue($v); + } + continue; + } + + $parts = explode(':', $line); + if (count($parts) >= 5) { + return [ + 'host' => trim($parts[0]) !== '' ? trim($parts[0]) : 'localhost', + 'port' => (int)(trim($parts[1]) !== '' ? trim($parts[1]) : '5432'), + 'database' => trim($parts[2]) !== '' ? trim($parts[2]) : 'postgres', + 'user' => trim($parts[3]), + 'password' => trim(implode(':', array_slice($parts, 4))), + ]; + } + } + + if (!empty($kv['PG_HOST']) || !empty($kv['PG_USER']) || !empty($kv['PG_PASSWORD'])) { + return [ + 'host' => $kv['PG_HOST'] ?? 'localhost', + 'port' => (int)($kv['PG_PORT'] ?? '5432'), + 'database' => $kv['PG_DATABASE'] ?? ($kv['PG_DBNAME'] ?? 'postgres'), + 'user' => $kv['PG_USER'] ?? 'postgres', + 'password' => $kv['PG_PASSWORD'] ?? '', + ]; + } + + throw new RuntimeException('Niepoprawny format pliku danych PostgreSQL: ' . $path); + } + + public function getString(string $key, string $default = ''): string + { + return isset($this->values[$key]) ? trim($this->values[$key]) : $default; + } + + public function getInt(string $key, int $default = 0): int + { + if (!isset($this->values[$key])) { + return $default; + } + + $value = trim($this->values[$key]); + if ($value === '' || !preg_match('/^-?[0-9]+$/', $value)) { + return $default; + } + + return (int)$value; + } + + public function getBool(string $key, bool $default = false): bool + { + if (!isset($this->values[$key])) { + return $default; + } + + return self::toBool($this->values[$key], $default); + } + + public function defaultDatabasesLimit(): int + { + $limit = $this->getInt('PG_PLUGIN_DEFAULT_DB_LIMIT', 5); + return $limit < 0 ? 0 : $limit; + } + + public function maxHostsPerUser(): int + { + $limit = $this->getInt('PG_PLUGIN_MAX_HOSTS_PER_USER', 30); + return $limit < 1 ? 1 : $limit; + } + + public function allowRemoteHosts(): bool + { + if (array_key_exists('allow_remote_hosts', $this->values)) { + return self::toBool($this->values['allow_remote_hosts'], true); + } + + return $this->getBool('PG_PLUGIN_ALLOW_REMOTE_HOSTS', true); + } + + public function hbaSyncScript(): string + { + $path = $this->getString('PG_PLUGIN_SUDO_SYNC_HBA', '/usr/local/directadmin/plugins/da-postgresql/scripts/setup/pg_hba_sync.sh'); + return $path !== '' ? $path : '/usr/local/directadmin/plugins/da-postgresql/scripts/setup/pg_hba_sync.sh'; + } + + public function enableBackup(): bool + { + return $this->getBool('ENABLE_BACKUP', true); + } + + public function enableUploadBackup(): bool + { + return $this->getBool('ENABLE_UPLOAD_BACKUP', true); + } + + public function enableAdminer(): bool + { + if (!$this->getBool('ENABLE_ADMINER', false)) { + return false; + } + + return $this->isAdminerInstalled(); + } + + public function adminerUrlPath(): string + { + $path = trim($this->getString('ADMINER_URL_PATH', '/adminer')); + if ($path === '') { + return '/adminer'; + } + + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $path = rtrim($path, '/'); + return $path !== '' ? $path : '/adminer'; + } + + public function adminerPublicBaseUrl(): string + { + $url = trim($this->getString('ADMINER_PUBLIC_BASE_URL', '')); + if ($url === '') { + return ''; + } + + $url = rtrim($url, '/'); + if (!preg_match('#^https?://#i', $url)) { + return ''; + } + + return $url; + } + + public function adminerPublic(): bool + { + return $this->getBool('ADMINER_PUBLIC', false); + } + + public function adminerSsoDir(): string + { + $path = trim($this->getString('ADMINER_SSO_DIR', '/var/www/html/adminer/runtime')); + if ($path === '') { + $path = '/var/www/html/adminer/runtime'; + } + return rtrim($path, '/'); + } + + public function adminerTicketTtl(): int + { + $ttl = $this->getInt('ADMINER_TICKET_TTL', 120); + if ($ttl < 10) { + return 10; + } + if ($ttl > 300) { + return 300; + } + return $ttl; + } + + public function adminerSessionTtl(): int + { + $ttl = $this->getInt('ADMINER_SESSION_TTL', 900); + if ($ttl < 60) { + return 60; + } + if ($ttl > 3600) { + return 3600; + } + return $ttl; + } + + public function adminerRoleTtl(): int + { + $ttl = $this->getInt('ADMINER_ROLE_TTL', 1200); + if ($ttl < 120) { + return 120; + } + if ($ttl > 7200) { + return 7200; + } + return $ttl; + } + + private function isAdminerInstalled(): bool + { + $indexFile = '/var/www/html/adminer/index.php'; + $coreFile = '/var/www/html/adminer/adminer-core.php'; + + return is_file($indexFile) && is_readable($indexFile) + && is_file($coreFile) && is_readable($coreFile); + } + + public function allowRestoreToOtherDatabase(): bool + { + return $this->getBool('ALLOW_RESTORE_TO_OTHER_DATABASE', true); + } + + public function enableTableCount(): bool + { + return $this->getBool('ENABLE_TABLE_COUNT', true); + } + + public function countDatabaseSize(): bool + { + return $this->getBool('COUNT_DATABASE_SIZE', true); + } + + public function compressBackup(): bool + { + return $this->getBool('COMPRESS_BACKUP', true); + } + + public function hitmeBackupLocation(string $daUser = ''): string + { + $path = $this->getString('HITME_BACKUP_LOCATION', '/home/admin/postgres_backup'); + if ($path === '') { + $path = '/home/admin/postgres_backup'; + } + + if ($daUser !== '') { + $path = str_replace(['DA_USER', 'da_user'], $daUser, $path); + } + + return rtrim($path, '/'); + } + + public function userBaseBackupDir(string $daUser = ''): string + { + $path = $this->getString('USER_BASE_BACKUP_DIR', '/home/DA_USER/postgres_backup'); + if ($path === '') { + $path = '/home/DA_USER/postgres_backup'; + } + + if ($daUser !== '') { + $path = str_replace(['DA_USER', 'da_user'], $daUser, $path); + } + + return rtrim($path, '/'); + } + + public function userBackupDateFormat(): string + { + $format = $this->getString('USER_BACKUP_DATE_FORMAT', 'd.m.Y-H.i'); + return $format !== '' ? $format : 'd.m.Y-H.i'; + } + + public function loadOrCreateSecret(string $path): string + { + if (is_readable($path)) { + $value = trim((string)file_get_contents($path)); + if ($value !== '') { + return $value; + } + } + + $secret = bin2hex(random_bytes(32)); + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0700, true); + } + + if (@file_put_contents($path, $secret) !== false) { + @chmod($path, 0600); + return $secret; + } + + return hash('sha256', __FILE__ . '|' . php_uname('n') . '|' . $path . '|da-postgresql'); + } + + public static function toBool(string $value, bool $default = false): bool + { + $v = strtolower(trim($value)); + if ($v === '') { + return $default; + } + + if (in_array($v, ['1', 'true', 'yes', 'y', 'on', 'checked'], true)) { + return true; + } + + if (in_array($v, ['0', 'false', 'no', 'n', 'off', 'unchecked'], true)) { + return false; + } + + return $default; + } + + private static function parseShValue(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + if ($value[0] === '\'' && substr($value, -1) === '\'') { + return substr($value, 1, -1); + } + + if ($value[0] === '"' && substr($value, -1) === '"') { + return stripcslashes(substr($value, 1, -1)); + } + + $value = preg_replace('/\s+#.*$/', '', $value); + return trim((string)$value); + } +} diff --git a/hooks/user_img.html b/hooks/user_img.html new file mode 100644 index 0000000..7b554fd --- /dev/null +++ b/hooks/user_img.html @@ -0,0 +1 @@ +PostgreSQL diff --git a/hooks/user_txt.html b/hooks/user_txt.html new file mode 100644 index 0000000..6b66ad8 --- /dev/null +++ b/hooks/user_txt.html @@ -0,0 +1 @@ +Bazy danych PostgreSQL diff --git a/images/postgresql.svg b/images/postgresql.svg new file mode 100644 index 0000000..b43b7c0 --- /dev/null +++ b/images/postgresql.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/images/user_icon.svg b/images/user_icon.svg new file mode 100644 index 0000000..b43b7c0 --- /dev/null +++ b/images/user_icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/lang/en.php b/lang/en.php new file mode 100644 index 0000000..02da84d --- /dev/null +++ b/lang/en.php @@ -0,0 +1,462 @@ + 'Dashboard', + 'Bazy danych' => 'Databases', + 'Wersja serwera PostgreSQL' => 'PostgreSQL server version', + 'Powrót' => 'Back', + 'Błąd' => 'Error', + 'Dostęp zablokowany' => 'Access blocked', + 'Włącz plugin przez custom package items: postgresql_enabled oraz ustaw limity.' => 'Enable the plugin via custom package items: postgresql_enabled and set limits.', + 'Plugin PostgreSQL jest wyłączony dla tego konta lub pakietu.' => 'PostgreSQL plugin is disabled for this account or package.', + 'Plugin PostgreSQL jest wyłączony: limit baz danych PostgreSQL ustawiono na 0 i nie zaznaczono opcji Bez Ograniczeń.' + => 'PostgreSQL plugin is disabled: PostgreSQL databases limit is set to 0 and Unlimited option is not enabled.', + 'Plugin PostgreSQL jest zablokowany przez reguły plugins_allow/plugins_deny.' => 'PostgreSQL plugin is blocked by plugins_allow/plugins_deny rules.', + 'Brak dostępu do pluginu PostgreSQL.' => 'No access to PostgreSQL plugin.', + 'Bazy danych PostgreSQL nie są dostępne dla Twojego konta - skontaktuj się z pomocą techniczną aby uzyskać pomoc i więcej informacji' + => 'PostgreSQL databases are not available for your account. Contact technical support for help and more information.', + 'Nie można ustalić użytkownika DirectAdmin.' => 'Cannot determine DirectAdmin user.', + 'ZARZĄDZAJ BAZAMI DANYCH' => 'MANAGE DATABASES', + 'ZARZĄDZAJ UŻYTKOWNIKAMI' => 'MANAGE USERS', + 'WGRAJ BACKUP' => 'UPLOAD BACKUP', + 'Użytkownicy baz danych' => 'Database users', + 'Backupy Baz danych' => 'Database backups', + 'PHPPGADMIN' => 'PHPPGADMIN', + 'ADMINER' => 'ADMINER', + + 'Zarządzanie bazami danych PostgreSQL' => 'PostgreSQL database management', + 'Lista baz danych' => 'Database list', + 'Utwórz bazę danych' => 'Create database', + 'Zarządzaj bazą danych' => 'Manage database', + 'Zarządzaj użytkownikami' => 'Manage users', + 'Brak baz danych do zarządzania.' => 'No databases to manage.', + 'Zarządzanie hasłami' => 'Password management', + 'Dostęp do baz danych' => 'Database access', + 'Dostęp użytkownika' => 'User access', + 'Przywileje' => 'Privileges', + 'Akcje' => 'Actions', + 'Akcja' => 'Action', + 'Baza' => 'Database', + 'Baza danych' => 'Database', + 'Nazwa bazy danych' => 'Database name', + 'Nazwa użytkownika' => 'Username', + 'Użytkownik' => 'User', + 'Użytkownik PostgreSQL' => 'PostgreSQL user', + 'Rozmiar' => 'Size', + 'Użytkownicy' => 'Users', + 'Liczba Tabel' => 'Table count', + 'Dozwolone hosty' => 'Allowed hosts', + 'wyłączone' => 'disabled', + 'Host' => 'Host', + 'Komentarz' => 'Comment', + 'Komentarz hosta' => 'Host comment', + 'Komentarz hosta (opcjonalnie)' => 'Host comment (optional)', + 'Komentarz do nowego hosta' => 'Comment for new host', + 'Wybierz' => 'Select', + 'Wybierz plik' => 'Choose file', + 'Zamknij' => 'Close', + 'W górę' => 'Up', + 'Nowy katalog' => 'New folder', + 'Utwórz' => 'Create', + 'Anuluj' => 'Cancel', + 'Przełącz' => 'Switch', + + 'Wykonaj Backup' => 'Create backup', + 'Pobierz Backup' => 'Download backup', + 'Usuń Backup' => 'Delete backup', + 'Usuń logi' => 'Delete logs', + 'Usuń bazę' => 'Delete database', + 'Usuń' => 'Delete', + 'Zmień hasło' => 'Change password', + 'Zapisz zmiany' => 'Save changes', + 'Zapisz hosty' => 'Save hosts', + 'Przywróć' => 'Restore', + 'Pokaż' => 'Show', + 'Pokaż logi' => 'Show logs', + 'Pobierz plik backupu' => 'Download backup file', + 'Pobieranie...' => 'Downloading...', + 'Nieudana odpowiedź serwera ({code})' => 'Server returned an error ({code})', + 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})' => 'Server returned HTML instead of file (URL: {url})', + 'Serwer zwrócił pusty plik.' => 'Server returned an empty file.', + 'Nie można pobrać pliku backupu.' => 'Cannot download backup file.', + 'Wysyłanie pliku...' => 'Uploading file...', + 'Nie udało się wysłać pliku backupu.' => 'Failed to upload backup file.', + 'Wybierz plik .sql/.gz/.sql.gz lub wskaż ścieżkę pliku na serwerze.' => 'Select a .sql/.gz/.sql.gz file or provide a server file path.', + 'Wskaż ścieżkę do pliku SQL lub SQL.GZ.' => 'Provide a path to a SQL or SQL.GZ file.', + 'Wybierz plik .sql/.gz/.sql.gz do przywrócenia.' => 'Select a .sql/.gz/.sql.gz file to restore.', + 'Nie udało się przygotować pliku do przywrócenia. Wybierz plik ponownie.' => 'Failed to prepare file for restore. Select the file again.', + + 'Przywróć Backup' => 'Restore Backup', + 'Lokalizacja pliku' => 'File location', + 'Przesyłanie pliku z komputera' => 'Upload file from computer', + 'Wskaż plik na koncie użytkownika' => 'Select file in user account', + 'Po dodaniu pliku zostanie on przesłany na serwer i dodany do kolejki przywracania.' => 'After selecting a file, it will be uploaded to the server and queued for restore.', + 'Dozwolone są tylko ścieżki z katalogu /home/{user}.' => 'Only paths from /home/{user} are allowed.', + 'Przeciągnij plik backupu tutaj' => 'Drag and drop the backup file here', + 'lub kliknij, aby wybrać plik' => 'or click to choose a file', + 'Plik' => 'File', + 'Rozmiar' => 'Size', + 'Usuń plik' => 'Remove file', + 'Backupy HITME.PL' => 'HITME.PL backups', + 'Backupy użytkownika' => 'User backups', + 'Backupy lokalne' => 'Local backups', + 'Backupy z pliku' => 'Backups from file', + 'Źródło: {source} (backupy z postgres_backup.sh)' => 'Source: {source} (postgres_backup.sh backups)', + 'Data:' => 'Date:', + 'Data i godzina' => 'Date and time', + 'Baza źródłowa' => 'Source database', + 'Plik' => 'File', + 'Baza docelowa' => 'Target database', + 'Kolejka zadań i logi' => 'Jobs queue and logs', + 'ID zadania' => 'Job ID', + 'Typ' => 'Type', + 'Rodzaj operacji' => 'Operation type', + 'Typ przywracania' => 'Restore type', + 'Tworzenie kopii zapasowej' => 'Backup creation', + 'Przywracanie kopii zapasowej' => 'Backup restore', + 'Przywracanie z nadpisaniem' => 'Restore with overwrite', + 'Przywracanie do nowej bazy' => 'Restore to new database', + 'Nie dotyczy' => 'Not applicable', + 'Status' => 'Status', + 'Start' => 'Start', + 'Aktualizacja' => 'Updated', + 'Podgląd logu' => 'View log', + 'Log zadania: {id}' => 'Job log: {id}', + 'Log zdarzenia' => 'Event log', + 'Data rozpoczęcia' => 'Start date', + 'Nazwa bazy' => 'Database name', + 'Akcja' => 'Action', + 'Log zadania' => 'Job log', + 'Kopiuj' => 'Copy', + 'Skopiowano' => 'Copied', + 'Oczekuje' => 'Pending', + 'W toku' => 'In progress', + 'Zakończone OK' => 'Completed OK', + 'Błąd' => 'Error', + 'Anulowane' => 'Canceled', + + 'Szczegóły wykonywania backupu' => 'Backup details', + 'Szczegóły zadania backupu' => 'Backup job details', + 'Na tej bazie trwa już operacja backup/restore' => 'A backup/restore operation is already running for this database', + 'Akcja:' => 'Action:', + 'Status:' => 'Status:', + 'Czas rozpoczęcia:' => 'Start time:', + 'Ostatnia aktualizacja:' => 'Last update:', + 'Logi zadania' => 'Job logs', + 'Log jest niedostępny dla tego zadania.' => 'Log is not available for this job.', + 'Szczegóły' => 'Details', + + 'Brak plików .sql/.gz' => 'No .sql/.gz files', + 'Brak katalogów' => 'No folders', + 'Nie udało się wczytać katalogów.' => 'Failed to load directories.', + 'Podaj nazwę katalogu.' => 'Enter folder name.', + 'Nie udało się utworzyć katalogu.' => 'Failed to create folder.', + + 'Obsługa przywracania backupów jest wyłączona (`ENABLE_UPLOAD_BACKUP=false`).' => 'Backup restore is disabled (`ENABLE_UPLOAD_BACKUP=false`).', + 'Dodano zadanie przywracania backupu lokalnego (ID: {id}).' => 'Local backup restore queued (ID: {id}).', + 'Dodano zadanie przywracania backupu z pliku (ID: {id}).' => 'File restore queued (ID: {id}).', + 'Brak backupów lokalnych dla wybranej daty.' => 'No local backups for the selected date.', + 'Brak baz danych do wyboru.' => 'No databases available for selection.', + 'Wybierz bazę docelową przy wybranym backupie.' => 'Select the target database for the chosen backup.', + 'Tryb przywracania' => 'Restore mode', + 'Nadpisanie istniejącej bazy danych (obecne działanie)' => 'Overwrite existing database (current behavior)', + 'Przywrócenie wskazanej bazy do nowej bazy danych' => 'Restore to a new database', + 'Nazwa docelowej bazy danych' => 'Target database name', + 'Wskaż docelową bazę danych.' => 'Provide a target database.', + 'Docelowa baza danych musi być inna niż źródłowa baza z backupu.' => 'Target database must be different from the source backup database.', + 'Wskazana docelowa baza danych nie istnieje. Utwórz ją ręcznie w DirectAdmin.' => 'The target database does not exist. Create it manually in DirectAdmin.', + 'Docelowa baza danych nie jest pusta. Usuń dane przed kontynuowaniem.' => 'Target database is not empty. Remove data before continuing.', + 'Nie można ustalić bazy z nazwy pliku.' => 'Cannot determine database name from the file name.', + 'Nie można ustalić docelowej bazy dla backupu lokalnego.' => 'Cannot determine target database for local backup.', + 'Wskaż plik `.sql`, `.gz` lub `.sql.gz` z komputera albo podaj ścieżkę pliku na serwerze (w katalogu `/home/{user}`).' + => 'Select a `.sql`, `.gz` or `.sql.gz` file from your computer or provide a server path (in `/home/{user}`).', + 'Plik backupu (.sql, .gz lub .sql.gz)' => 'Backup file (.sql, .gz or .sql.gz)', + 'Ścieżka pliku SQL / SQL.GZ (opcjonalnie)' => 'SQL/SQL.GZ file path (optional)', + 'Ścieżka pliku SQL / SQL.GZ' => 'SQL/SQL.GZ file path', + 'Dodaj przywracanie do kolejki' => 'Queue restore job', + 'Brak zadań backup/restore do wyświetlenia.' => 'No backup/restore jobs to display.', + 'Brak backupów HITME.PL dla wybranej daty.' => 'No HITME.PL backups for the selected date.', + 'Brak backupów użytkownika dla wybranej daty.' => 'No user backups for the selected date.', + 'Kalendarz backupów' => 'Backup calendar', + 'Wybierz aktywną datę w kalendarzu.' => 'Select an active date in the calendar.', + 'Wybierz godzinę backupu' => 'Select backup time', + 'Usunięto log zadania backupu.' => 'Backup job log deleted.', + 'Usunięto plik backupu: {path}' => 'Backup file deleted: {path}', + 'Czy na pewno chcesz usunąć backup bazy danych {db} z dn. {date}?' + => 'Are you sure you want to delete the backup of database {db} from {date}?', + 'Poprzedni miesiąc' => 'Previous month', + 'Następny miesiąc' => 'Next month', + 'Potwierdzenie operacji' => 'Confirm operation', + 'Tak, kontynuuj' => 'Yes, continue', + 'Nie' => 'No', + 'nadpisując obecne dane' => 'overwriting current data', + 'Czy na pewno chcesz przywrócić kopię zapasową bazy danych {source} z dn. {date} {overwrite} znajdujące się w bazie danych {target}?' + => 'Are you sure you want to restore the backup of database {source} from {date}, {overwrite}, in database {target}?', + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z dn. {date} do innej bazy danych o nazwie {target}?' + => 'Are you sure you want to restore data from the backup of database {source} from {date} into another database named {target}?', + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z pliku {file} do innej bazy danych o nazwie {target}?' + => 'Are you sure you want to restore data from the backup of database {source} from file {file} into another database named {target}?', + 'Uwaga: dane znajdujące się obecnie w bazie danych {target} zostaną usunięte i nadpisane danymi z kopii zapasowej dla bazy {source}.' + => 'Warning: the data currently in database {target} will be deleted and overwritten with the backup data for database {source}.', + 'Czy na pewno chcesz przywrócić plik kopii zapasowej {file} do bazy danych {target}?' + => 'Are you sure you want to restore backup file {file} to database {target}?', + 'Uwaga: jeśli baza danych {target} zawiera dane, zostaną one nadpisane.' + => 'Warning: if database {target} already contains data, it will be overwritten.', + 'Zadanie przywrócenia kopii zapasowej {file} do bazy danych {db} zostało dodane do kolejki' + => 'Restore job for backup file {file} to database {db} has been added to the queue.', + + 'Szczegółowe informacje o bazie danych.' => 'Detailed database information.', + 'Szczegółowe informacje o użytkowniku.' => 'Detailed user information.', + 'Domyślne kodowanie znaków' => 'Default character encoding', + 'Domyślne porównywanie znaków' => 'Default collation', + 'Widoki' => 'Views', + 'Wyzwalacze' => 'Triggers', + 'Rutyny i procedury' => 'Routines and procedures', + + 'Lista użytkowników, którzy mają dostęp do tej bazy danych wraz z podsumowaniem uprawnień.' + => 'List of users with access to this database and their privileges summary.', + 'Przyznaj dostęp kolejnemu użytkownikowi' => 'Grant access to another user', + '+ Przyznaj pełny dostęp' => '+ Grant full access', + 'Przywileje użytkownika:' => 'User privileges:', + 'Brak użytkowników z dostępem do bazy.' => 'No users with access to this database.', + + 'Baza:' => 'Database:', + 'Użytkownik:' => 'User:', + 'Nazwa użytkownika:' => 'Username:', + 'Nowe hasło' => 'New password', + 'Wpisz nowe hasło' => 'Enter new password', + 'Generuj hasło' => 'Generate password', + 'Pokaż lub ukryj hasło' => 'Show or hide password', + 'Dostęp do baz danych' => 'Database access', + 'Przyznaj dostęp do kolejnej bazy danych' => 'Grant access to another database', + 'Przyznaj pełny dostęp' => 'Grant full access', + 'Dodaj użytkownika' => 'Add user', + 'Utwórz nowego użytkownika PostgreSQL' => 'Create a new PostgreSQL user', + 'Utwórz użytkownika' => 'Create user', + 'Przypisz do baz danych (opcjonalnie)' => 'Assign to databases (optional)', + 'Możesz pozostawić użytkownika bez przypisania do baz i nadać dostęp później.' + => 'You can leave the user without database assignments and grant access later.', + 'Brak użytkowników PostgreSQL. Dodaj pierwszego użytkownika.' + => 'No PostgreSQL users. Add the first user.', + 'Aby zarządzać użytkownikami najpierw utwórz użytkownika PostgreSQL.' + => 'To manage users, first create a PostgreSQL user.', + 'Dopuszczone hosty' => 'Allowed hosts', + 'Zezwalaj na dostęp z' => 'Allow access from', + 'Dodaj IPv4' => 'Add IPv4', + 'Dodatkowy host IPv4' => 'Additional IPv4 host', + 'Dodatkowy host IPv4 (opcjonalnie)' => 'Additional IPv4 host (optional)', + 'Komentarz hosta (opcjonalnie)' => 'Host comment (optional)', + 'Komentarz' => 'Comment', + '+ Dodaj host' => '+ Add host', + 'host stały' => 'fixed host', + 'Brak dozwolonych hostów.' => 'No allowed hosts.', + 'Brak przypisanych baz danych.' => 'No assigned databases.', + + 'Uprawnienia' => 'Privileges', + 'Uprawnienia początkowe' => 'Initial privileges', + 'Uprawnienia dla przypisywanych baz' => 'Privileges for assigned databases', + 'Domyślny dostęp' => 'Default access', + '`localhost` jest dodawany automatycznie.' => '`localhost` is added automatically.', + 'Brak baz do przypisania.' => 'No databases to assign.', + 'Hosty zdalne są wyłączone w konfiguracji pluginu (`allow_remote_hosts=0`).' => 'Remote hosts are disabled in plugin configuration (`allow_remote_hosts=0`).', + 'Brak przypisań' => 'No assignments', + + 'Tryb prosty tworzy użytkownika o tej samej nazwie co baza i automatycznie generuje hasło.' + => 'Simple mode creates a user with the same name as the database and generates a password automatically.', + 'Użytkownik o tej samej nazwie i bezpiecznym haśle zostanie wygenerowany automatycznie.' + => 'A user with the same name and a secure password will be generated automatically.', + 'Tryb zaawansowany' => 'Advanced mode', + 'Nowy użytkownik' => 'New user', + 'Istniejący użytkownik' => 'Existing user', + 'Istniejący użytkownik' => 'Existing user', + 'Nazwa użytkownika PostgreSQL' => 'PostgreSQL username', + 'Hasło użytkownika' => 'User password', + 'W trybie zaawansowanym hasło jest wymagane.' => 'Password is required in advanced mode.', + 'UTWÓRZ' => 'CREATE', + + 'Dane dostępowe do bazy danych' => 'Database access credentials', + 'Dane dostępowe użytkownika PostgreSQL' => 'PostgreSQL user access credentials', + 'Nazwa hosta:' => 'Host name:', + 'Baza danych:' => 'Database:', + 'Nazwa użytkownika:' => 'Username:', + 'Hasło:' => 'Password:', + 'Przypisane bazy danych:' => 'Assigned databases:', + + 'Wykorzystano limit baz danych (' => 'Database limit reached (', + 'Bez ograniczeń' => 'Unlimited', + 'Nie znaleziono baz danych...' => 'No databases found...', + 'Ilość baz danych' => 'Database count', + 'Lista wszystkich baz danych wraz z liczbą użytkowników' => 'List of all databases with user count', + ' i rozmiarem bazy' => ' and database size', + ' oraz liczbą tabel' => ' and table count', + 'Backupy wykonywane przez użytkownika zapisywane są w: ' => 'User backups are stored in: ', + + 'Czy na pewno chcesz usunąć backup bazy danych - ' => 'Are you sure you want to delete the database backup - ', + 'Czy na pewno chcesz usunąć użytkownika ' => 'Are you sure you want to delete user ', + 'Potwierdź usunięcie backupu' => 'Confirm backup deletion', + 'Czy na pewno chcesz usunąć następującą bazę danych - ' => 'Are you sure you want to delete the following database - ', + 'Do bazy danych ' => 'For database ', + 'przypisani są użytkownicy, z poniższej listy wybierz użytkowników, których chcesz usunąć.' + => 'there are users assigned. Select which users you want to delete from the list below.', + 'Wybierz' => 'Select', + 'Przypisane Bazy danych' => 'Assigned databases', + 'Do wskazanej bazy nie są przypisani użytkownicy PostgreSQL.' => 'No PostgreSQL users are assigned to the selected database.', + 'Potwierdź usunięcie bazy' => 'Confirm database deletion', + 'Uwaga: wskazany użytkownik jest przypisany do wielu baz danych i jego usunięcie spowoduje brak możliwości dostępu do innych baz danych przy użyciu danych logowania użytkownika ' + => 'Warning: the selected user is assigned to multiple databases and deleting this user will remove access to other databases using the credentials of user ', + 'Uwaga użytkownik ' => 'Warning: user ', + ' jest przypisany do następującej bazy danych ' => ' is assigned to the following database ', + ' jest przypisany do następujących baz danych:' => ' is assigned to the following databases:', + + 'Usuwanie Użytkowników' => 'Delete users', + 'Brak użytkowników do usunięcia.' => 'No users to delete.', + 'Przypisane bazy' => 'Assigned databases', + 'Bazy, których jest właścicielem' => 'Databases owned', + 'Usuń zaznaczonych użytkowników' => 'Delete selected users', + 'Usuń użytkownika' => 'Delete user', + 'Potwierdzenie Usuwania Użytkowników' => 'Confirm user deletion', + 'Brak poprawnych użytkowników do usunięcia.' => 'No valid users to delete.', + 'Potwierdź usunięcie zaznaczonych użytkowników PostgreSQL. Operacja jest nieodwracalna.' + => 'Confirm deletion of selected PostgreSQL users. This action is irreversible.', + 'Potwierdź usunięcie' => 'Confirm deletion', + 'Potwierdź usunięcie użytkownika' => 'Confirm user deletion', + + 'Usuwanie Baz Danych' => 'Delete databases', + 'Brak baz do usunięcia.' => 'No databases to delete.', + 'Przypisani użytkownicy' => 'Assigned users', + 'Usuń zaznaczone bazy' => 'Delete selected databases', + 'Potwierdzenie Usuwania Baz Danych' => 'Confirm database deletion', + 'Brak poprawnych baz do usunięcia.' => 'No valid databases to delete.', + 'Potwierdź usunięcie baz danych. Operacja jest nieodwracalna.' => 'Confirm deletion of databases. This action is irreversible.', + 'Usuń także użytkowników przypisanych do usuwanych baz (jeżeli po usunięciu baz nie mają już innych przypisań)' + => 'Also delete users assigned to deleted databases (if they have no other assignments after deletion)', + + 'Przypisywanie Bazy do Użytkownika' => 'Assign database to user', + '-- wybierz bazę --' => '-- select database --', + '-- wybierz użytkownika --' => '-- select user --', + '-- wybierz nowego użytkownika --' => '-- select new user --', + '-- wybierz istniejącego użytkownika --' => '-- select existing user --', + 'Przypisz bazę' => 'Assign database', + + 'Hosty Zdalne Użytkownika' => 'User remote hosts', + 'Zarządzanie hostami zdalnymi jest wyłączone (`allow_remote_hosts=0`).' + => 'Remote host management is disabled (`allow_remote_hosts=0`).', + 'Informacja' => 'Information', + 'Dozwolone są wyłącznie pojedyncze adresy IPv4, bez CIDR i bez nazw hostów.' + => 'Only single IPv4 addresses are allowed, without CIDR and without hostnames.', + 'Aktualne hosty' => 'Current hosts', + 'Brak zapisanych hostów.' => 'No hosts saved.', + 'usuń' => 'remove', + + 'Nieprawidłowy lub wygasły token CSRF.' => 'Invalid or expired CSRF token.', + 'Wskaż ścieżkę do pliku SQL lub SQL.GZ.' => 'Provide a path to SQL or SQL.GZ file.', + 'Nie można odczytać pliku: ' => 'Cannot read file: ', + 'Ścieżka pliku musi znajdować się w katalogu /home/' => 'File path must be under /home/', + 'Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.' => 'Only .sql, .gz or .sql.gz files are allowed.', + 'Nie można utworzyć katalogu: ' => 'Cannot create directory: ', + 'Nie można utworzyć katalogu upload: ' => 'Cannot create upload directory: ', + 'Nie udało się skopiować pliku do kolejki restore.' => 'Failed to copy file to restore queue.', + 'Nie udało się zapisać przesłanego pliku do kolejki restore.' => 'Failed to save uploaded file to restore queue.', + 'Nie udało się przesłać pliku backupu (kod błędu: ' => 'Upload failed (error code: ', + 'Nie można odczytać przesłanego pliku backupu.' => 'Cannot read uploaded backup file.', + 'Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.' => 'Select a .sql, .gz or .sql.gz file to restore.', + 'Tryb przesyłania został zaktualizowany. Odśwież stronę i spróbuj ponownie.' => 'Upload mode has been updated. Refresh the page and try again.', + 'Nie znaleziono zadania lub brak dostępu.' => 'Job not found or access denied.', + 'Nieprawidłowy identyfikator zadania.' => 'Invalid job ID.', + 'Nie znaleziono zadania.' => 'Job not found.', + 'Nie znaleziono logu.' => 'Log not found.', + 'Nie udało się połączyć z PostgreSQL.' => 'Failed to connect to PostgreSQL.', + 'Nie znaleziono bazy danych: ' => 'Database not found: ', + 'Brak uprawnień do nadania.' => 'No privileges to grant.', + 'Nieprawidłowy identyfikator PostgreSQL: ' => 'Invalid PostgreSQL identifier: ', + 'Identyfikator PostgreSQL przekracza 63 znaki: ' => 'PostgreSQL identifier exceeds 63 characters: ', + 'Nie udało się bezpiecznie zacytować wartości SQL.' => 'Failed to safely quote SQL value.', + 'Niepoprawny format pliku danych PostgreSQL: ' => 'Invalid PostgreSQL credentials file format: ', + 'Brak pliku z danymi PostgreSQL: ' => 'Missing PostgreSQL credentials file: ', + + 'Brak identyfikatora zadania backupu.' => 'Missing backup job ID.', + 'Plik backupu nie istnieje na serwerze.' => 'Backup file does not exist on the server.', + 'Brak uprawnień odczytu pliku backupu.' => 'No read permission for backup file.', + 'Nie można pobrać backupu (nagłówki zostały już wysłane).' => 'Cannot download backup (headers already sent).', + 'Nie można odczytać pliku backupu do pobrania.' => 'Cannot read backup file for download.', + 'Tworzenie backupu jest wyłączone w konfiguracji pluginu.' => 'Backup creation is disabled in plugin configuration.', + 'Wskazana baza danych nie należy do konta: ' => 'Selected database does not belong to this account: ', + 'Osiągnięto limit baz danych dla konta (' => 'Database limit reached (', + 'Baza danych już istnieje: ' => 'Database already exists: ', + 'Wybrany użytkownik PostgreSQL nie istnieje: ' => 'Selected PostgreSQL user does not exist: ', + 'Użytkownik PostgreSQL już istnieje: ' => 'PostgreSQL user already exists: ', + 'Hasło użytkownika jest wymagane w trybie zaawansowanym.' => 'Password is required in advanced mode.', + 'Hasło użytkownika musi mieć co najmniej 12 znaków.' => 'Password must be at least 12 characters.', + 'Nowe hasło musi mieć minimum 12 znaków.' => 'New password must be at least 12 characters.', + 'Przekroczono limit hostów dla użytkownika PostgreSQL.' => 'Host limit exceeded for PostgreSQL user.', + 'Host lokalny nie może zostać usunięty.' => 'Local host cannot be removed.', + 'Baza i uprawnienia zapisane, ale synchronizacja pg_hba nie powiodła się: ' + => 'Database and privileges saved, but pg_hba sync failed: ', + 'Baza danych została utworzona.' => 'Database has been created.', + 'Kopia zapasowa bazy danych ' => 'Backup of database ', + ' została dodana do kolejki' => ' has been added to the queue', + 'Nie wskazano bazy danych do usunięcia.' => 'No database selected for deletion.', + 'Usuwanie wykonane, ale synchronizacja pg_hba nie powiodła się: ' + => 'Deletion completed, but pg_hba sync failed: ', + 'Usunięto bazę danych ' => 'Deleted database ', + ' i użytkownika ' => ' and user ', + ' i następujących użytkowników przypisanych do bazy danych ' + => ' and the following users assigned to database ', + 'Usunięto bazę danych: ' => 'Database deleted: ', + 'Usunięto użytkowników: ' => 'Users deleted: ', + 'Nie udało się usunąć części użytkowników: ' => 'Failed to delete some users: ', + 'Nie można odczytać statusów zadań backup/restore: ' => 'Cannot read backup/restore job statuses: ', + 'Wskazane zadanie nie jest zadaniem backupu.' => 'Selected job is not a backup job.', + 'Backup nie został zakończony powodzeniem.' => 'Backup did not complete successfully.', + 'nieznana baza' => 'unknown database', + 'Usunięto plik backupu: ' => 'Deleted backup file: ', + 'Nie można pobrać backupu: ' => 'Cannot download backup: ', + 'Brak dostępu do pluginu.' => 'No access to plugin.', + 'Brak parametru job.' => 'Missing job parameter.', + 'Plik backupu nie istnieje lub brak odczytu.' => 'Backup file does not exist or is not readable.', + 'Nagłówki zostały już wysłane.' => 'Headers have already been sent.', + + 'Baza nie należy do konta DA: ' => 'Database does not belong to the DirectAdmin account: ', + 'Baza nie należy do bieżącego użytkownika DA: ' => 'Database does not belong to current DirectAdmin user: ', + 'Użytkownik PostgreSQL nie należy do konta DA: ' => 'PostgreSQL user does not belong to the DirectAdmin account: ', + 'Użytkownik PostgreSQL nie należy do bieżącego konta DA: ' => 'PostgreSQL user does not belong to current DirectAdmin account: ', + 'Nieprawidłowy użytkownik PostgreSQL: ' => 'Invalid PostgreSQL user: ', + 'Wybierz przynajmniej jedno uprawnienie.' => 'Select at least one privilege.', + 'Odebrano dostęp użytkownikowi ' => 'Revoked access for user ', + 'Przyznano dostęp użytkownikowi ' => 'Granted access to user ', + 'Zaktualizowano uprawnienia użytkownika ' => 'Updated privileges for user ', + 'Przypisanie wykonane, ale synchronizacja pg_hba nie powiodła się: ' + => 'Assignment completed, but pg_hba sync failed: ', + 'Przypisano bazę ' => 'Assigned database ', + + 'Utworzono użytkownika ' => 'Created user ', + ' bez przypisanych baz danych.' => ' without assigned databases.', + 'Usunięto użytkownika ' => 'Deleted user ', + 'Nie można usunąć użytkownika, ponieważ jest właścicielem baz: ' + => 'Cannot delete user because the user owns databases: ', + 'Użytkownik został utworzony, ale synchronizacja pg_hba nie powiodła się: ' + => 'User was created, but pg_hba sync failed: ', + 'Brak użytkowników PostgreSQL do zarządzania.' => 'No PostgreSQL users to manage.', + + 'Przyznano dostęp do bazy ' => 'Granted access to database ', + 'Odebrano dostęp do bazy ' => 'Revoked access to database ', + 'Dodano host ' => 'Added host ', + 'Usunięto host ' => 'Removed host ', + 'Host dodany, ale synchronizacja pg_hba nie powiodła się: ' => 'Host added, but pg_hba sync failed: ', + 'Host usunięty, ale synchronizacja pg_hba nie powiodła się: ' => 'Host removed, but pg_hba sync failed: ', + + 'Nie wybrano użytkowników do usunięcia.' => 'No users selected for deletion.', + 'Nie usunięto: ' => 'Not deleted: ', + + 'Nie wybrano baz danych do usunięcia.' => 'No databases selected for deletion.', + 'Pominięto bazę spoza konta: ' => 'Skipped database outside account: ', + 'Operacja wykonana, ale synchronizacja pg_hba nie powiodła się: ' + => 'Operation completed, but pg_hba sync failed: ', + 'Usunięto bazy: ' => 'Databases deleted: ', + 'Usunięto użytkowników powiązanych z usuniętymi bazami: ' + => 'Users linked to deleted databases were removed: ', + 'Użytkownicy pozostawieni: ' => 'Users kept: ', + + 'Zarządzanie hostami zdalnymi jest wyłączone (`allow_remote_hosts=0`).' => 'Remote host management is disabled (`allow_remote_hosts=0`).', + 'Hosty zapisane, ale synchronizacja pg_hba nie powiodła się: ' => 'Hosts saved, but pg_hba sync failed: ', + 'Zaktualizowano hosty dostępu dla użytkownika ' => 'Updated access hosts for user ', +]; diff --git a/lang/pl.php b/lang/pl.php new file mode 100644 index 0000000..2236fcf --- /dev/null +++ b/lang/pl.php @@ -0,0 +1,50 @@ + 'Pulpit', + 'Bazy danych' => 'Bazy danych', + 'Przywróć Backup' => 'Przywróć Backup', + 'Przeciągnij plik backupu tutaj' => 'Przeciągnij plik backupu tutaj', + 'lub kliknij, aby wybrać plik' => 'lub kliknij, aby wybrać plik', + 'Plik' => 'Plik', + 'Rozmiar' => 'Rozmiar', + 'Usuń plik' => 'Usuń plik', + 'Wersja serwera PostgreSQL' => 'Wersja serwera PostgreSQL', + 'Powrót' => 'Powrót', + 'Błąd' => 'Błąd', + 'ZARZĄDZAJ BAZAMI DANYCH' => 'ZARZĄDZAJ BAZAMI DANYCH', + 'ZARZĄDZAJ UŻYTKOWNIKAMI' => 'ZARZĄDZAJ UŻYTKOWNIKAMI', + 'WGRAJ BACKUP' => 'WGRAJ BACKUP', + 'PHPPGADMIN' => 'PHPPGADMIN', + 'Pobieranie...' => 'Pobieranie...', + 'Nieudana odpowiedź serwera ({code})' => 'Nieudana odpowiedź serwera ({code})', + 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})' => 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})', + 'Serwer zwrócił pusty plik.' => 'Serwer zwrócił pusty plik.', + 'Nie można pobrać pliku backupu.' => 'Nie można pobrać pliku backupu.', + 'Pobierz plik backupu' => 'Pobierz plik backupu', + 'Brak plików .sql/.gz' => 'Brak plików .sql/.gz', + 'Brak katalogów' => 'Brak katalogów', + 'Nie udało się wczytać katalogów.' => 'Nie udało się wczytać katalogów.', + 'Podaj nazwę katalogu.' => 'Podaj nazwę katalogu.', + 'Nie udało się utworzyć katalogu.' => 'Nie udało się utworzyć katalogu.', + ]; + } + + if (isset($replacements[$text])) { + return $replacements[$text]; + } + + return $text; +} + +$pl = []; +foreach ($en as $key => $_value) { + $pl[$key] = pl_normalize_text((string)$key); +} + +return $pl; diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..401a02d --- /dev/null +++ b/php.ini @@ -0,0 +1,10 @@ +allow_url_fopen=Off +allow_url_include=Off +display_errors=Off +log_errors=On +error_reporting=E_ALL +session.use_strict_mode=1 +file_uploads=On +upload_max_filesize=4096M +post_max_size=4096M +max_file_uploads=20 diff --git a/plugin-settings.conf b/plugin-settings.conf new file mode 100644 index 0000000..9dbee89 --- /dev/null +++ b/plugin-settings.conf @@ -0,0 +1,50 @@ +# Ustawienia pluginu PostgreSQL Manager. +# PG_PLUGIN_MAX_HOSTS_PER_USER - maksymalna liczba hostów zdalnych przypisanych do jednego użytkownika PostgreSQL. +PG_PLUGIN_MAX_HOSTS_PER_USER=30 +# allow_remote_hosts - czy pokazywać UI i obsługę hostów zdalnych (1=tak, 0=nie). +# Gdy 1, synchronizacja hostów aktualizuje pg_hba, postgresql.conf oraz CSF. +allow_remote_hosts=1 +# Aliasy kompatybilności wstecznej. +PG_PLUGIN_ALLOW_REMOTE_HOSTS=1 +# PG_PLUGIN_SUDO_SYNC_HBA - ścieżka do skryptu synchronizacji pg_hba.conf. +PG_PLUGIN_SUDO_SYNC_HBA=/usr/local/directadmin/plugins/da-postgresql/scripts/setup/pg_hba_sync.sh +# PG_PLUGIN_DEFAULT_DB_LIMIT - domyślny limit baz dla użytkownika, gdy brak wartości w pakiecie/użytkowniku. +PG_PLUGIN_DEFAULT_DB_LIMIT=5 +# ENABLE_BACKUP - czy pokazywać i umożliwiać tworzenie backupu baz danych (true/false). +ENABLE_BACKUP=true +# ENABLE_UPLOAD_BACKUP - czy pokazywać UI i obsługę przywracania backupu (true/false). +ENABLE_UPLOAD_BACKUP=true +# ENABLE_ADMINER - czy pokazywać integrację Adminera w panelu pluginu (true/false). +ENABLE_ADMINER=false +# ADMINER_PUBLIC - czy pozwalać na bezpośredni dostęp do /adminer bez SSO (true/false). +# Gdy true: dostęp publiczny do formularza logowania Adminera (bez autologowania), tylko PostgreSQL. +# Gdy false: wejście bez ticketu SSO pokaże placeholder informujący o dostępie przez DirectAdmin. +# Zmiana zaczyna działać po następnym odświeżeniu pluginu (synchronizacja runtime/config.json). +ADMINER_PUBLIC=false +# ADMINER_PUBLIC_BASE_URL - opcjonalny publiczny adres WWW (bez końcowego /), np. https://panel.example.com. +# Gdy pusty, plugin automatycznie użyje aktualnego hosta bez portu DirectAdmin (np. bez :2222). +ADMINER_PUBLIC_BASE_URL= +# ADMINER_URL_PATH - ścieżka URL aliasu Adminera. +ADMINER_URL_PATH=/adminer +# ADMINER_SSO_DIR - katalog runtime ticketów SSO dla Adminera. +ADMINER_SSO_DIR=/var/www/html/adminer/runtime +# ADMINER_TICKET_TTL - ważność ticketu SSO (sekundy). +ADMINER_TICKET_TTL=120 +# ADMINER_SESSION_TTL - maksymalny czas sesji Adminera otwartej z pluginu (sekundy). +ADMINER_SESSION_TTL=900 +# ADMINER_ROLE_TTL - ważność tymczasowej roli PostgreSQL dla sesji Adminera (sekundy). +ADMINER_ROLE_TTL=1200 +# ALLOW_RESTORE_TO_OTHER_DATABASE - czy pozwalać na przywracanie lokalnych backupów do innej bazy (true/false). +ALLOW_RESTORE_TO_OTHER_DATABASE=true +# ENABLE_TABLE_COUNT - czy zliczać tabele w bazach i pokazywać kolumnę Liczba Tabel (true/false). +ENABLE_TABLE_COUNT=true +# COUNT_DATABASE_SIZE - czy zliczać rozmiar baz i pokazywać kolumnę Rozmiar (true/false). +COUNT_DATABASE_SIZE=true +# COMPRESS_BACKUP - czy backupy tworzone przez plugin kompresować do .sql.gz (true/false). +COMPRESS_BACKUP=true +# HITME_BACKUP_LOCATION - główny katalog lokalnych backupów PostgreSQL. +HITME_BACKUP_LOCATION=/home/admin/postgres_backup +# USER_BASE_BACKUP_DIR - katalog bazowy backupów wykonywanych przez użytkownika (DA_USER podmieniany automatycznie). +USER_BASE_BACKUP_DIR=/home/DA_USER/postgres_backup +# USER_BACKUP_DATE_FORMAT - format katalogu daty backupu użytkownika (PHP date()). +USER_BACKUP_DATE_FORMAT=d.m.Y-H.i diff --git a/plugin.conf b/plugin.conf new file mode 100644 index 0000000..299a782 --- /dev/null +++ b/plugin.conf @@ -0,0 +1,8 @@ +name=da-postgresql +id=da-postgresql +type=user +author=HITME.PL +version=1.5.41 +active=no +installed=no +user_run_as=root diff --git a/scripts/backup_job.sh b/scripts/backup_job.sh new file mode 100644 index 0000000..193cdb9 --- /dev/null +++ b/scripts/backup_job.sh @@ -0,0 +1,343 @@ +#!/bin/bash +set -Eeuo pipefail + +ERROR_MSG="" +BACKUP_OK=0 +DA_USER="" +DB_NAME="" +JOB_ID="" +LOG_FILE="" +TMP_DB_USER="" +TMP_DB_PASS="" +TMP_DB_ACTIVE=0 + +JOB_FILE="${1:-}" +if [ -z "$JOB_FILE" ] || [ ! -f "$JOB_FILE" ]; then + echo "da-postgresql: missing job file" >&2 + exit 1 +fi + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CONFIG_FILE="${PLUGIN_DIR}/plugin-settings.conf" +LOG_DIR="${PLUGIN_DIR}/data/logs" +mkdir -p "$LOG_DIR" + +urlencode() { + local s="$1" + local out="" + local i c + for ((i=0; i<${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9.~_-]) out+="$c" ;; + ' ') out+='%20' ;; + *) printf -v c '%%%02X' "'$c"; out+="$c" ;; + esac + done + printf '%s' "$out" +} + +notify_user() { + local code="$1" + if [ -z "${DA_USER:-}" ]; then + return 0 + fi + + local subject message + if [ "$code" -eq 0 ] && [ "$BACKUP_OK" -eq 1 ]; then + subject="Backup bazy PostgreSQL zakończony pomyślnie" + message="Backup bazy ${DB_NAME} został wykonany. Plik: ${TARGET_FILE:-nieznany}." + else + subject="Błąd wykonywania backupu bazy PostgreSQL" + if [ -n "$ERROR_MSG" ]; then + message="Backup bazy ${DB_NAME} nie powiódł się: ${ERROR_MSG}" + else + message="Backup bazy ${DB_NAME} nie powiódł się. Sprawdź log zadania." + fi + fi + + local task_queue="/usr/local/directadmin/data/task.queue" + local users="select1%3D$(urlencode "$DA_USER")" + local line="action=notify&value=users&subject=$(urlencode "$subject")&message=$(urlencode "$message")&users=${users}" + printf "%s\n" "$line" >> "$task_queue" +} + +fail() { + local msg="$1" + ERROR_MSG="$msg" + if [ -n "${LOG_FILE:-}" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${msg}" >> "$LOG_FILE" + fi +} + +log_unexpected_error() { + local line_no="$1" + local command="$2" + local code="$3" + if [ -z "${LOG_FILE:-}" ]; then + return 0 + fi + if [ -n "${ERROR_MSG:-}" ]; then + return 0 + fi + + ERROR_MSG="Nieoczekiwany błąd wykonywania backupu (kod ${code}, linia ${line_no})." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG} CMD: ${command}" >> "$LOG_FILE" +} + +load_plugin_settings() { + if [ ! -f "$CONFIG_FILE" ]; then + fail "Brak pliku konfiguracyjnego pluginu: $CONFIG_FILE" + exit 1 + fi + # shellcheck disable=SC1090 + source "$CONFIG_FILE" +} + +quote_sh_value() { + local value="$1" + value="${value//\'/\'\\\'\'}" + printf "'%s'" "$value" +} + +set_job_var() { + local key="$1" + local value="$2" + local tmp + local owner_group="" + if [ -f "$JOB_FILE" ]; then + owner_group="$(stat -c '%u:%g' "$JOB_FILE" 2>/dev/null || stat -f '%u:%g' "$JOB_FILE" 2>/dev/null || true)" + fi + tmp="$(mktemp)" + grep -v "^${key}=" "$JOB_FILE" > "$tmp" 2>/dev/null || true + printf "%s=%s\n" "$key" "$(quote_sh_value "$value")" >> "$tmp" + mv "$tmp" "$JOB_FILE" + chmod 600 "$JOB_FILE" 2>/dev/null || true + if [ "$(id -u)" -eq 0 ] && [ -n "$owner_group" ]; then + chown "$owner_group" "$JOB_FILE" 2>/dev/null || true + fi +} + + +load_pg_credentials() { + local creds_file="/usr/local/directadmin/conf/postgresql.conf" + PGHOST_VAL="" + PGPORT_VAL="" + PGUSER_VAL="" + PGPASSWORD_VAL="" + + if [ ! -r "$creds_file" ]; then + fail "Brak pliku poświadczeń PostgreSQL: $creds_file" + exit 1 + fi + + local line + line="$(grep -Ev '^[[:space:]]*($|#)' "$creds_file" | head -n1 || true)" + if [ -z "$line" ]; then + fail "Pusty plik poświadczeń PostgreSQL: $creds_file" + exit 1 + fi + + if [[ "$line" == *=* ]]; then + while IFS='=' read -r key value; do + key="$(echo "$key" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]')" + value="$(echo "$value" | sed -E 's/[[:space:]]+#.*$//' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + case "$key" in + PG_HOST) PGHOST_VAL="$value" ;; + PG_PORT) PGPORT_VAL="$value" ;; + PG_USER) PGUSER_VAL="$value" ;; + PG_PASSWORD) PGPASSWORD_VAL="$value" ;; + esac + done < <(grep -Ev '^[[:space:]]*($|#)' "$creds_file") + else + local rest + PGHOST_VAL="${line%%:*}" + rest="${line#*:}" + PGPORT_VAL="${rest%%:*}" + rest="${rest#*:}" + rest="${rest#*:}" + PGUSER_VAL="${rest%%:*}" + PGPASSWORD_VAL="${rest#*:}" + fi + + [ -n "$PGHOST_VAL" ] || PGHOST_VAL="localhost" + [ -n "$PGPORT_VAL" ] || PGPORT_VAL="5432" + [ -n "$PGUSER_VAL" ] || PGUSER_VAL="postgres" + if [ -z "$PGPASSWORD_VAL" ]; then + fail "Brak hasła PostgreSQL w /usr/local/directadmin/conf/postgresql.conf" + exit 1 + fi +} + +resolve_bins() { + PSQL_BIN="$(command -v psql 2>/dev/null || true)" + PG_DUMP_BIN="$(command -v pg_dump 2>/dev/null || true)" + + if [ -z "$PSQL_BIN" ] || [ -z "$PG_DUMP_BIN" ]; then + fail "Brak wymaganych binariów PostgreSQL (psql/pg_dump)." + exit 1 + fi +} + +sql_escape_literal() { + printf '%s' "$1" | sed "s/'/''/g" +} + +psql_admin() { + local database="$1" + shift + PGPASSWORD="$PGPASSWORD_VAL" "$PSQL_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$PGUSER_VAL" -d "$database" -v ON_ERROR_STOP=1 "$@" +} + +cleanup_tmp_user() { + if [ "$TMP_DB_ACTIVE" -ne 1 ] || [ -z "${TMP_DB_USER:-}" ]; then + return 0 + fi + + psql_admin postgres -c "DROP ROLE IF EXISTS \"$TMP_DB_USER\";" >/dev/null 2>&1 || true + TMP_DB_ACTIVE=0 +} + +on_exit() { + local code="$1" + if [ "$code" -ne 0 ] && [ -z "${ERROR_MSG:-}" ] && [ -n "${LOG_FILE:-}" ]; then + ERROR_MSG="Zadanie backupu zakończyło się błędem (kod ${code}) bez zarejestrowanego szczegółu." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG}" >> "$LOG_FILE" + fi + cleanup_tmp_user + notify_user "$code" +} +trap 'log_unexpected_error "$LINENO" "$BASH_COMMAND" "$?"' ERR +trap 'on_exit $?' EXIT + +random_tmp_password() { + local value + value="$(od -An -N48 -tx1 /dev/urandom 2>/dev/null | tr -d '[:space:]')" + if [ -z "$value" ]; then + value="$(date +%s%N)${RANDOM}${RANDOM}" + fi + printf '%s' "${value:0:32}" +} + +# shellcheck disable=SC1090 +source "$JOB_FILE" + +JOB_ID="${JOB_ID:-$(basename "$JOB_FILE" .env)}" +DA_USER="${DA_USER:-}" +DB_NAME="${DB_NAME:-}" +DATE_DIR="${DATE_DIR:-$(date +%Y-%m-%d)}" +COMPRESS_BACKUP="${COMPRESS_BACKUP:-1}" +USER_BASE_BACKUP_DIR="${USER_BASE_BACKUP_DIR:-/home/${DA_USER}/postgres_backup}" +JOB_COMPRESS_BACKUP="${COMPRESS_BACKUP}" +JOB_USER_BASE_BACKUP_DIR="${USER_BASE_BACKUP_DIR}" + +LOG_FILE="${LOG_DIR}/${JOB_ID}.log" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Start backup job ${JOB_ID}" > "$LOG_FILE" + +if [ -z "$DA_USER" ] || [ -z "$DB_NAME" ]; then + fail "Brak DA_USER lub DB_NAME w pliku zadania." + exit 1 +fi + +if [[ "$DB_NAME" != "${DA_USER}_"* ]]; then + fail "Baza ${DB_NAME} nie należy do użytkownika ${DA_USER}." + exit 1 +fi + +load_plugin_settings +COMPRESS_BACKUP="${JOB_COMPRESS_BACKUP:-$COMPRESS_BACKUP}" +USER_BASE_BACKUP_DIR="${JOB_USER_BASE_BACKUP_DIR:-$USER_BASE_BACKUP_DIR}" +load_pg_credentials +resolve_bins + +if [[ "$COMPRESS_BACKUP" =~ ^(true|yes|on)$ ]]; then + COMPRESS_BACKUP="1" +fi +if [[ "$COMPRESS_BACKUP" =~ ^(false|no|off)$ ]]; then + COMPRESS_BACKUP="0" +fi + +TARGET_DIR="${USER_BASE_BACKUP_DIR%/}/${DATE_DIR}" +if [[ "$DATE_DIR" == *".."* || "$DATE_DIR" == */* ]]; then + fail "Nieprawidłowa nazwa katalogu backupu." + exit 1 +fi + +if ! mkdir -p "$TARGET_DIR" 2>>"$LOG_FILE"; then + fail "Brak dostępu do katalogu backupu: ${TARGET_DIR}. Uruchom worker jako root lub popraw uprawnienia USER_BASE_BACKUP_DIR." + exit 1 +fi +if ! chmod 755 "$TARGET_DIR" >/dev/null 2>&1; then + fail "Nie można ustawić uprawnień katalogu backupu: ${TARGET_DIR}." + exit 1 +fi +if [ "$(id -u)" -eq 0 ] && [ -n "$DA_USER" ]; then + chown "$DA_USER:$DA_USER" "$TARGET_DIR" 2>/dev/null || true +fi + +TARGET_FILE="${TARGET_DIR}/${DB_NAME}.sql" +if [ "$COMPRESS_BACKUP" = "1" ]; then + TARGET_FILE="${TARGET_FILE}.gz" +fi +set_job_var "TARGET_FILE" "$TARGET_FILE" + +DB_ESC="$(sql_escape_literal "$DB_NAME")" +DB_EXISTS="$(psql_admin postgres -At -c "SELECT 1 FROM pg_database WHERE datname='${DB_ESC}'" 2>/dev/null || true)" +if [ -z "$DB_EXISTS" ]; then + fail "Baza danych ${DB_NAME} nie istnieje." + exit 1 +fi + +TMP_DB_USER="pgtmp_bkp_$(printf '%s' "$JOB_ID" | tr -cd 'A-Za-z0-9' | tr '[:upper:]' '[:lower:]' | head -c 18)" +[ -n "$TMP_DB_USER" ] || TMP_DB_USER="pgtmp_bkp_$(date +%s)" +TMP_DB_PASS="$(random_tmp_password)" +PASS_ESC="$(sql_escape_literal "$TMP_DB_PASS")" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tworzenie tymczasowego użytkownika backupu ${TMP_DB_USER}" >> "$LOG_FILE" +psql_admin postgres -c "SET password_encryption='scram-sha-256'; DROP ROLE IF EXISTS \"$TMP_DB_USER\"; CREATE ROLE \"$TMP_DB_USER\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOREPLICATION PASSWORD '${PASS_ESC}';" >> "$LOG_FILE" 2>&1 +TMP_DB_ACTIVE=1 + +psql_admin postgres -c "GRANT CONNECT, TEMPORARY ON DATABASE \"$DB_NAME\" TO \"$TMP_DB_USER\";" >> "$LOG_FILE" 2>&1 +psql_admin "$DB_NAME" -c "GRANT USAGE ON SCHEMA public TO \"$TMP_DB_USER\"; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"$TMP_DB_USER\"; GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO \"$TMP_DB_USER\"; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO \"$TMP_DB_USER\";" >> "$LOG_FILE" 2>&1 + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tworzenie backupu bazy ${DB_NAME}" >> "$LOG_FILE" +if [ "$COMPRESS_BACKUP" = "1" ]; then + set +e + PGPASSWORD="$TMP_DB_PASS" "$PG_DUMP_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$TMP_DB_USER" -d "$DB_NAME" --no-owner --no-privileges --format=plain 2>>"$LOG_FILE" | gzip -9 > "$TARGET_FILE" + RC=$? + set -e +else + set +e + PGPASSWORD="$TMP_DB_PASS" "$PG_DUMP_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$TMP_DB_USER" -d "$DB_NAME" --no-owner --no-privileges --format=plain > "$TARGET_FILE" 2>>"$LOG_FILE" + RC=$? + set -e +fi + +if [ "$RC" -ne 0 ]; then + fail "Nie udało się utworzyć backupu bazy ${DB_NAME}." + rm -f "$TARGET_FILE" >/dev/null 2>&1 || true + exit 1 +fi + +if [ "$(id -u)" -eq 0 ] && [ -n "$DA_USER" ]; then + chown "$DA_USER:$DA_USER" "$TARGET_FILE" 2>/dev/null || true +fi +chmod 644 "$TARGET_FILE" 2>/dev/null || true +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup zapisany: ${TARGET_FILE}" >> "$LOG_FILE" + +CURRENT_EXT=".sql" +if [ "$COMPRESS_BACKUP" = "1" ]; then + CURRENT_EXT="${CURRENT_EXT}.gz" +fi +CURRENT_FILE="${TARGET_DIR}/${DB_NAME}-current${CURRENT_EXT}" +if [ -n "$USER_BASE_BACKUP_DIR" ] && [ -d "$USER_BASE_BACKUP_DIR" ]; then + find "$USER_BASE_BACKUP_DIR" -maxdepth 3 -type f -name "${DB_NAME}-current.sql*" -exec rm -f {} + >/dev/null 2>&1 || true +fi +ln -sfn "$TARGET_FILE" "$CURRENT_FILE" 2>/dev/null || cp -f "$TARGET_FILE" "$CURRENT_FILE" >/dev/null 2>&1 || true +if [ "$(id -u)" -eq 0 ] && [ -n "$DA_USER" ]; then + chown "$DA_USER:$DA_USER" "$CURRENT_FILE" 2>/dev/null || true +fi +chmod 644 "$CURRENT_FILE" 2>/dev/null || true + +BACKUP_OK=1 +exit 0 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..3bb4010 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,156 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CPIF="/usr/local/directadmin/data/admin/custom_package_items.conf" +SUDOERS_FILE="/etc/sudoers.d/directadmin-postgresql-plugin" +CRON_FILE="/etc/cron.d/da-postgresql-jobs" +IS_ROOT=0 +if [ "$(id -u)" -eq 0 ]; then + IS_ROOT=1 +fi + +set_permissions() { + local owner="$1" + local mode="$2" + local path="$3" + if [ -e "$path" ]; then + chown "$owner" "$path" 2>/dev/null || true + chmod "$mode" "$path" 2>/dev/null || true + fi +} + +set_mode_if_exists() { + local mode="$1" + local path="$2" + if [ -e "$path" ]; then + chmod "$mode" "$path" 2>/dev/null || true + fi +} + +bootstrap_custom_package_items() { + if [ "$IS_ROOT" -ne 1 ]; then + echo "postgresql: warning: uruchom jako root, aby zaktualizować $CPIF" >&2 + return 0 + fi + + touch "$CPIF" + + sed -i '/^postgresql_enabled=/d' "$CPIF" || true + sed -i '/^postgresql=/d' "$CPIF" || true + sed -i '/^upostgresql=/d' "$CPIF" || true + sed -i '/^postgresql_max_databases=/d' "$CPIF" || true + sed -i '/^postgresql_unlimited=/d' "$CPIF" || true + sed -i '/^upostgresql_max_databases=/d' "$CPIF" || true + + cat >> "$CPIF" <<'EOF' +postgresql_enabled=type=checkbox&string=Enable da-postgresql&desc=Zezwól na dostęp do baz danych PostgreSQL&default=no +postgresql=type=text&string=Bazy PostgreSQL&desc=&default=5 +upostgresql=type=checkbox&string=Bez ograniczeń&desc=&default=no&custom=autofocus%20onfocus%3D%22%28function%28c%29%7Bif%28c.dataset.pgInit%3D%3D%3D%271%27%29%7Breturn%3B%7Dc.dataset.pgInit%3D%271%27%3Bvar%20l%3Ddocument.querySelector%28%22input%5Bname%3D%27postgresql%27%5D%22%29%3Bif%28%21l%29%7Breturn%3B%7Dvar%20r%3Dc.closest%28%27tr%27%29%3Bvar%20td%3Dl.closest%28%27td%27%29%3Bif%28r%26%26td%29%7Bvar%20w%3Ddocument.getElementById%28%27da-pg-unlim-wrap%27%29%3Bif%28%21w%29%7Bw%3Ddocument.createElement%28%27label%27%29%3Bw.id%3D%27da-pg-unlim-wrap%27%3Bw.style.display%3D%27inline-flex%27%3Bw.style.alignItems%3D%27center%27%3Bw.style.gap%3D%278px%27%3Bw.style.marginLeft%3D%2712px%27%3Bw.appendChild%28c%29%3Bw.appendChild%28document.createTextNode%28%27Bez%20ogranicze%C5%84%27%29%29%3Btd.style.display%3D%27flex%27%3Btd.style.alignItems%3D%27center%27%3Btd.style.justifyContent%3D%27space-between%27%3Btd.appendChild%28w%29%3B%7Dr.style.display%3D%27none%27%3B%7Dvar%20s%3Dfunction%28%29%7Bl.disabled%3D%21%21c.checked%3B%7D%3Bc.addEventListener%28%27change%27%2Cs%29%3Bs%28%29%3B%7D%29%28this%29%22%20onchange%3D%22var%20l%3Ddocument.querySelector%28%22input%5Bname%3D%27postgresql%27%5D%22%29%3Bif%28l%29%7Bl.disabled%3D%21%21this.checked%3B%7D%22 +EOF + + set_permissions diradmin:diradmin 640 "$CPIF" +} + +bootstrap_sudoers() { + if [ "$IS_ROOT" -ne 1 ]; then + echo "postgresql: warning: uruchom jako root, aby dodać sudoers dla synchronizacji pg_hba" >&2 + return 0 + fi + + cat > "$SUDOERS_FILE" <&2 + return 0 + fi + + touch "$CRON_FILE" + if ! grep -Fq '/usr/local/directadmin/plugins/da-postgresql/scripts/worker.sh' "$CRON_FILE"; then + echo '* * * * * root /usr/local/directadmin/plugins/da-postgresql/scripts/worker.sh >/dev/null 2>&1' >> "$CRON_FILE" + fi + chmod 644 "$CRON_FILE" 2>/dev/null || true +} + +bootstrap_adminer() { + local adminer_setup="$PLUGIN_DIR/scripts/setup/adminer_install.sh" + + if [ "$IS_ROOT" -ne 1 ]; then + echo "postgresql: warning: uruchom jako root, aby zainstalować Adminera." >&2 + return 0 + fi + + if [ ! -f "$adminer_setup" ]; then + echo "postgresql: error: brak skryptu instalacji Adminera: $adminer_setup" >&2 + return 1 + fi + + bash "$adminer_setup" +} + +activate_plugin() { + local conf="$PLUGIN_DIR/plugin.conf" + if [ -f "$conf" ]; then + sed -i 's/^active=.*/active=yes/' "$conf" || true + sed -i 's/^installed=.*/installed=yes/' "$conf" || true + fi +} + +mkdir -p "$PLUGIN_DIR/data" +mkdir -p "$PLUGIN_DIR/exec" "$PLUGIN_DIR/exec/lib" "$PLUGIN_DIR/exec/handlers" +mkdir -p "$PLUGIN_DIR/data/jobs/pending" "$PLUGIN_DIR/data/jobs/processing" "$PLUGIN_DIR/data/jobs/done" +mkdir -p "$PLUGIN_DIR/data/logs" "$PLUGIN_DIR/data/lock" "$PLUGIN_DIR/data/pids" "$PLUGIN_DIR/data/cancel" +mkdir -p "$PLUGIN_DIR/data/uploads" + +if [ "$IS_ROOT" -eq 1 ]; then + chown -R diradmin:diradmin "$PLUGIN_DIR" 2>/dev/null || true +else + echo "postgresql: warning: uruchom jako root, aby ustawić właściciela plików pluginu (w przeciwnym razie może wystąpić biała strona)." >&2 +fi + +find "$PLUGIN_DIR" -type d -exec chmod 755 {} + 2>/dev/null || true +set_mode_if_exists 700 "$PLUGIN_DIR/data" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs/pending" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs/processing" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs/done" +set_mode_if_exists 700 "$PLUGIN_DIR/data/logs" +set_mode_if_exists 700 "$PLUGIN_DIR/data/lock" +set_mode_if_exists 700 "$PLUGIN_DIR/data/pids" +set_mode_if_exists 700 "$PLUGIN_DIR/data/cancel" +set_mode_if_exists 700 "$PLUGIN_DIR/data/uploads" +find "$PLUGIN_DIR" -type f -name '*.html' -exec chmod 755 {} + 2>/dev/null || true +find "$PLUGIN_DIR" -type f -name '*.raw' -exec chmod 755 {} + 2>/dev/null || true +find "$PLUGIN_DIR" -type f -name '*.css' -exec chmod 644 {} + 2>/dev/null || true +find "$PLUGIN_DIR/scripts" -type f -name '*.sh' -exec chmod 755 {} + 2>/dev/null || true +find "$PLUGIN_DIR" -type f -name '*.php' -exec chmod 640 {} + 2>/dev/null || true +set_mode_if_exists 644 "$PLUGIN_DIR/plugin.conf" +set_mode_if_exists 644 "$PLUGIN_DIR/plugin-settings.conf" +set_mode_if_exists 644 "$PLUGIN_DIR/php.ini" + +if [ ! -f "$PLUGIN_DIR/data/secret.key" ]; then + tr -dc 'a-f0-9' "$PLUGIN_DIR/data/secret.key" || true +fi +set_mode_if_exists 600 "$PLUGIN_DIR/data/secret.key" + +touch "$PLUGIN_DIR/error.log" 2>/dev/null || true +if [ "$IS_ROOT" -eq 1 ]; then + chown diradmin:diradmin "$PLUGIN_DIR/error.log" 2>/dev/null || true +fi +set_mode_if_exists 640 "$PLUGIN_DIR/error.log" + +bootstrap_custom_package_items +bootstrap_sudoers +bootstrap_worker_cron +bootstrap_adminer +activate_plugin + +echo "da-postgresql: instalacja zakończona." +echo "Przypomnienie: zapisz pakiety i user.conf w DirectAdmin, aby odświeżyć custom package items." diff --git a/scripts/restore_job.sh b/scripts/restore_job.sh new file mode 100644 index 0000000..72e2c03 --- /dev/null +++ b/scripts/restore_job.sh @@ -0,0 +1,578 @@ +#!/bin/bash +set -Eeuo pipefail + +ERROR_MSG="" +RESTORE_OK=0 +DA_USER="" +DB_NAME="" +JOB_ID="" +LOG_FILE="" +RESTORE_MODE="" +SOURCE_DB_NAME="" +USE_OWNER_RESTORE=0 +USE_SANITIZE=0 +PRE_CLEAN=0 +TARGET_DB_EMPTY=0 +TMP_DB_USER="" +TMP_DB_PASS="" +TMP_DB_ACTIVE=0 +ORIG_DB_OWNER="" +DB_OWNER_SWITCHED=0 + +JOB_FILE="${1:-}" +if [ -z "$JOB_FILE" ] || [ ! -f "$JOB_FILE" ]; then + echo "da-postgresql: missing job file" >&2 + exit 1 +fi + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CONFIG_FILE="${PLUGIN_DIR}/plugin-settings.conf" +LOG_DIR="${PLUGIN_DIR}/data/logs" +mkdir -p "$LOG_DIR" + +urlencode() { + local s="$1" + local out="" + local i c + for ((i=0; i<${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9.~_-]) out+="$c" ;; + ' ') out+='%20' ;; + *) printf -v c '%%%02X' "'$c"; out+="$c" ;; + esac + done + printf '%s' "$out" +} + +notify_user() { + local code="$1" + if [ -z "${DA_USER:-}" ]; then + return 0 + fi + + local subject message + if [ "$code" -eq 0 ] && [ "$RESTORE_OK" -eq 1 ]; then + subject="Przywracanie bazy PostgreSQL zakończone pomyślnie" + message="Przywracanie bazy ${DB_NAME} z pliku ${SOURCE_FILE:-nieznany} zakończone." + else + subject="Błąd przywracania bazy PostgreSQL" + if [ -n "$ERROR_MSG" ]; then + message="Przywracanie bazy ${DB_NAME} nie powiodło się: ${ERROR_MSG}" + else + message="Przywracanie bazy ${DB_NAME} nie powiodło się. Sprawdź log zadania." + fi + fi + + local task_queue="/usr/local/directadmin/data/task.queue" + local users="select1%3D$(urlencode "$DA_USER")" + local line="action=notify&value=users&subject=$(urlencode "$subject")&message=$(urlencode "$message")&users=${users}" + printf "%s\n" "$line" >> "$task_queue" +} + +fail() { + local msg="$1" + ERROR_MSG="$msg" + if [ -n "${LOG_FILE:-}" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${msg}" >> "$LOG_FILE" + fi +} + +log_unexpected_error() { + local line_no="$1" + local command="$2" + local code="$3" + if [ -z "${LOG_FILE:-}" ]; then + return 0 + fi + if [ -n "${ERROR_MSG:-}" ]; then + return 0 + fi + + ERROR_MSG="Nieoczekiwany błąd wykonywania przywracania (kod ${code}, linia ${line_no})." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG} CMD: ${command}" >> "$LOG_FILE" +} + +load_plugin_settings() { + if [ ! -f "$CONFIG_FILE" ]; then + fail "Brak pliku konfiguracyjnego pluginu: $CONFIG_FILE" + exit 1 + fi + # shellcheck disable=SC1090 + source "$CONFIG_FILE" +} + +load_pg_credentials() { + local creds_file="/usr/local/directadmin/conf/postgresql.conf" + PGHOST_VAL="" + PGPORT_VAL="" + PGUSER_VAL="" + PGPASSWORD_VAL="" + + if [ ! -r "$creds_file" ]; then + fail "Brak pliku poświadczeń PostgreSQL: $creds_file" + exit 1 + fi + + local line + line="$(grep -Ev '^[[:space:]]*($|#)' "$creds_file" | head -n1 || true)" + if [ -z "$line" ]; then + fail "Pusty plik poświadczeń PostgreSQL: $creds_file" + exit 1 + fi + + if [[ "$line" == *=* ]]; then + while IFS='=' read -r key value; do + key="$(echo "$key" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]')" + value="$(echo "$value" | sed -E 's/[[:space:]]+#.*$//' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + case "$key" in + PG_HOST) PGHOST_VAL="$value" ;; + PG_PORT) PGPORT_VAL="$value" ;; + PG_USER) PGUSER_VAL="$value" ;; + PG_PASSWORD) PGPASSWORD_VAL="$value" ;; + esac + done < <(grep -Ev '^[[:space:]]*($|#)' "$creds_file") + else + local rest + PGHOST_VAL="${line%%:*}" + rest="${line#*:}" + PGPORT_VAL="${rest%%:*}" + rest="${rest#*:}" + rest="${rest#*:}" + PGUSER_VAL="${rest%%:*}" + PGPASSWORD_VAL="${rest#*:}" + fi + + [ -n "$PGHOST_VAL" ] || PGHOST_VAL="localhost" + [ -n "$PGPORT_VAL" ] || PGPORT_VAL="5432" + [ -n "$PGUSER_VAL" ] || PGUSER_VAL="postgres" + if [ -z "$PGPASSWORD_VAL" ]; then + fail "Brak hasła PostgreSQL w /usr/local/directadmin/conf/postgresql.conf" + exit 1 + fi +} + +resolve_bins() { + PSQL_BIN="$(command -v psql 2>/dev/null || true)" + if [ -z "$PSQL_BIN" ]; then + fail "Brak binarium psql." + exit 1 + fi +} + +sql_escape_literal() { + printf '%s' "$1" | sed "s/'/''/g" +} + +psql_admin() { + local database="$1" + shift + PGPASSWORD="$PGPASSWORD_VAL" "$PSQL_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$PGUSER_VAL" -d "$database" -v ON_ERROR_STOP=1 "$@" +} + +role_exists() { + local role="$1" + if [ -z "$role" ]; then + return 1 + fi + local esc + esc="$(sql_escape_literal "$role")" + local ok + ok="$(psql_admin postgres -At -c "SELECT 1 FROM pg_roles WHERE rolname='${esc}'" 2>/dev/null || true)" + [ -n "$ok" ] +} + +resolve_db_owner() { + local owner + owner="$(psql_admin postgres -At -c "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname='${DB_ESC}'" 2>/dev/null | head -n1 | tr -d '\r' || true)" + if role_exists "$owner"; then + printf '%s' "$owner" + return 0 + fi + if role_exists "$DA_USER"; then + printf '%s' "$DA_USER" + return 0 + fi + if role_exists "$PGUSER_VAL"; then + printf '%s' "$PGUSER_VAL" + return 0 + fi + printf '%s' "" + return 0 +} + +database_is_empty() { + local db_name="$1" + local has_objects + has_objects="$( + psql_admin "$db_name" -At -c " + SELECT 1 + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT LIKE 'pg_%' + AND n.nspname <> 'information_schema' + LIMIT 1; + " 2>/dev/null || true + )" + [ -z "$has_objects" ] +} + +run_restore_as_owner() { + local owner="$1" + if [ -z "$owner" ]; then + fail "Nie można ustalić właściciela bazy ${DB_NAME}." + return 1 + fi + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Przywracanie jako właściciel ${owner}" >> "$LOG_FILE" + if [[ "$SOURCE_FILE" == *.gz ]]; then + set +e + { printf 'SET ROLE "%s";\n' "$owner"; gunzip -c "$SOURCE_FILE" | sanitize_restore_compat; } | psql_admin "$DB_NAME" >> "$LOG_FILE" 2>&1 + RC=$? + set -e + else + set +e + { printf 'SET ROLE "%s";\n' "$owner"; sanitize_restore_compat < "$SOURCE_FILE"; } | psql_admin "$DB_NAME" >> "$LOG_FILE" 2>&1 + RC=$? + set -e + fi + return "$RC" +} + +sanitize_pg_dump() { + local src_db="$1" + local dst_db="$2" + awk -v src="$src_db" -v dst="$dst_db" ' + function lower(s) { return tolower(s) } + { + line = $0 + l = lower(line) + if (l ~ /^[[:space:]]*\\connect/ || l ~ /^[[:space:]]*\\c[[:space:]]/) { next } + if (l ~ /^[[:space:]]*create[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*drop[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*alter[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*comment[[:space:]]+on[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*revoke[[:space:]].*[[:space:]]on[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*grant[[:space:]].*[[:space:]]on[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*set[[:space:]]+role[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*set[[:space:]]+session[[:space:]]+authorization/) { next } + if (l ~ /^[[:space:]]*reset[[:space:]]+role/) { next } + if (l ~ /^[[:space:]]*reset[[:space:]]+session[[:space:]]+authorization/) { next } + if (l ~ /^[[:space:]]*alter[[:space:]].*[[:space:]]owner[[:space:]]+to[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*comment[[:space:]]+on[[:space:]]+schema[[:space:]]+public[[:space:]]+is[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*owner[[:space:]]+to[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*alter[[:space:]]+default[[:space:]]+privileges/) { next } + if (l ~ /^[[:space:]]*reassign[[:space:]]+owned/) { next } + if (l ~ /^[[:space:]]*drop[[:space:]]+owned/) { next } + print line + } + ' +} + +sanitize_restore_compat() { + awk ' + function lower(s) { return tolower(s) } + { + line = $0 + l = lower(line) + # PostgreSQL < 17: parameter transaction_timeout does not exist. + if (l ~ /^[[:space:]]*set[[:space:]]+"?transaction_timeout"?[[:space:]]*=/) { next } + if (l ~ /^[[:space:]]*reset[[:space:]]+"?transaction_timeout"?[[:space:]]*;/) { next } + print line + } + ' +} + +preclean_database() { + local owner="$1" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Czyszczenie bazy ${DB_NAME} przed przywróceniem" >> "$LOG_FILE" + local schemas + schemas="$(psql_admin "$DB_NAME" -At -c "SELECT nspname FROM pg_namespace WHERE nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema'" 2>>"$LOG_FILE" || true)" + if [ -n "$schemas" ]; then + while IFS= read -r schema; do + schema="$(printf '%s' "$schema" | tr -d '\r')" + [ -z "$schema" ] && continue + psql_admin "$DB_NAME" -c "DROP SCHEMA IF EXISTS \"$schema\" CASCADE;" >> "$LOG_FILE" 2>&1 || true + done <<< "$schemas" + fi + if [ -n "$owner" ]; then + psql_admin "$DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS public AUTHORIZATION \"$owner\";" >> "$LOG_FILE" 2>&1 || true + psql_admin "$DB_NAME" -c "GRANT ALL ON SCHEMA public TO \"$owner\";" >> "$LOG_FILE" 2>&1 || true + fi +} + +restore_db_owner() { + if [ "$DB_OWNER_SWITCHED" -ne 1 ] || [ -z "$ORIG_DB_OWNER" ] || [ -z "${TMP_DB_USER:-}" ]; then + return 0 + fi + + if ! role_exists "$ORIG_DB_OWNER"; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Oryginalny właściciel bazy ${ORIG_DB_OWNER} nie istnieje, pomijam przywrócenie właściciela." >> "$LOG_FILE" + DB_OWNER_SWITCHED=0 + return 0 + fi + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Przywracanie właściciela bazy ${DB_NAME} na ${ORIG_DB_OWNER}" >> "$LOG_FILE" + set +e + psql_admin "$DB_NAME" -c "REASSIGN OWNED BY \"$TMP_DB_USER\" TO \"$ORIG_DB_OWNER\";" >> "$LOG_FILE" 2>&1 + local rc1=$? + psql_admin "$DB_NAME" -c "DROP OWNED BY \"$TMP_DB_USER\";" >> "$LOG_FILE" 2>&1 + local rc2=$? + psql_admin postgres -c "ALTER DATABASE \"$DB_NAME\" OWNER TO \"$ORIG_DB_OWNER\";" >> "$LOG_FILE" 2>&1 + local rc3=$? + set -e + + if [ "$rc1" -ne 0 ] || [ "$rc3" -ne 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Nie udało się w pełni przywrócić właściciela bazy ${DB_NAME}." >> "$LOG_FILE" + return 1 + fi + DB_OWNER_SWITCHED=0 + return 0 +} + +cleanup_tmp_user() { + if [ "$TMP_DB_ACTIVE" -ne 1 ] || [ -z "${TMP_DB_USER:-}" ]; then + return 0 + fi + + psql_admin postgres -c "DROP ROLE IF EXISTS \"$TMP_DB_USER\";" >/dev/null 2>&1 || true + TMP_DB_ACTIVE=0 +} + +on_exit() { + local code="$1" + if [ "$code" -ne 0 ] && [ -z "${ERROR_MSG:-}" ] && [ -n "${LOG_FILE:-}" ]; then + ERROR_MSG="Zadanie przywracania zakończyło się błędem (kod ${code}) bez zarejestrowanego szczegółu." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG}" >> "$LOG_FILE" + fi + restore_db_owner || true + cleanup_tmp_user + notify_user "$code" +} +trap 'log_unexpected_error "$LINENO" "$BASH_COMMAND" "$?"' ERR +trap 'on_exit $?' EXIT + +random_tmp_password() { + local value + value="$(od -An -N48 -tx1 /dev/urandom 2>/dev/null | tr -d '[:space:]')" + if [ -z "$value" ]; then + value="$(date +%s%N)${RANDOM}${RANDOM}" + fi + printf '%s' "${value:0:32}" +} + +path_in_dir() { + local path="$1" + local dir="$2" + local real_path real_dir + real_path="$(realpath "$path" 2>/dev/null || true)" + real_dir="$(realpath "$dir" 2>/dev/null || true)" + if [ -z "$real_path" ] || [ -z "$real_dir" ]; then + return 1 + fi + if [ "$real_path" = "$real_dir" ]; then + return 0 + fi + case "$real_path" in + "$real_dir"/*) return 0 ;; + esac + return 1 +} + +# shellcheck disable=SC1090 +source "$JOB_FILE" + +JOB_ID="${JOB_ID:-$(basename "$JOB_FILE" .env)}" +DA_USER="${DA_USER:-}" +DB_NAME="${DB_NAME:-}" +SOURCE_FILE="${SOURCE_FILE:-}" +SOURCE_KIND="${SOURCE_KIND:-local}" +RESTORE_MODE="${RESTORE_MODE:-overwrite}" +SOURCE_DB_NAME="${SOURCE_DB_NAME:-}" + +RESTORE_MODE="$(printf '%s' "$RESTORE_MODE" | tr '[:upper:]' '[:lower:]')" +if [ "$RESTORE_MODE" != "new_db" ]; then + RESTORE_MODE="overwrite" +fi + +LOG_FILE="${LOG_DIR}/${JOB_ID}.log" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Start restore job ${JOB_ID}" > "$LOG_FILE" + +if [ -z "$DA_USER" ] || [ -z "$DB_NAME" ] || [ -z "$SOURCE_FILE" ]; then + fail "Brak DA_USER, DB_NAME lub SOURCE_FILE w pliku zadania." + exit 1 +fi + +if [[ "$DB_NAME" != "${DA_USER}_"* ]]; then + fail "Baza ${DB_NAME} nie należy do użytkownika ${DA_USER}." + exit 1 +fi + +load_plugin_settings +load_pg_credentials +resolve_bins + +if [ ! -f "$SOURCE_FILE" ] || [ ! -r "$SOURCE_FILE" ]; then + fail "Nie można odczytać pliku backupu: ${SOURCE_FILE}" + exit 1 +fi + +case "$SOURCE_FILE" in + *.sql|*.sql.gz|*.gz) ;; + *) + fail "Dozwolone są wyłącznie pliki .sql, .gz i .sql.gz." + exit 1 + ;; +esac + +USER_BACKUP_ROOT="${HITME_BACKUP_LOCATION:-/home/admin/postgres_backup}" +UPLOAD_ROOT="${PLUGIN_DIR}/data/uploads/${DA_USER}" +if [ "$SOURCE_KIND" = "local" ]; then + if ! path_in_dir "$SOURCE_FILE" "$USER_BACKUP_ROOT"; then + fail "Plik backupu lokalnego znajduje się poza dozwolonym katalogiem użytkownika." + exit 1 + fi +else + if ! path_in_dir "$SOURCE_FILE" "$UPLOAD_ROOT"; then + fail "Plik do przywrócenia znajduje się poza katalogiem upload." + exit 1 + fi +fi + +DB_ESC="$(sql_escape_literal "$DB_NAME")" +DB_EXISTS="$(psql_admin postgres -At -c "SELECT 1 FROM pg_database WHERE datname='${DB_ESC}'" 2>/dev/null || true)" +if [ -z "$DB_EXISTS" ]; then + fail "Docelowa baza danych ${DB_NAME} nie istnieje." + exit 1 +fi + +if [ "$RESTORE_MODE" = "overwrite" ] && [ -n "$SOURCE_DB_NAME" ] && [ "$SOURCE_DB_NAME" = "$DB_NAME" ]; then + USE_OWNER_RESTORE=1 + PRE_CLEAN=1 +fi +if [ "$RESTORE_MODE" = "new_db" ]; then + PRE_CLEAN=1 +fi +if [ "$SOURCE_KIND" = "local" ]; then + USE_SANITIZE=1 +fi +if [ "$SOURCE_KIND" = "file" ]; then + SOURCE_DB_NAME="" + USE_SANITIZE=1 +fi +if [ "$RESTORE_MODE" = "new_db" ] && [ -n "$SOURCE_DB_NAME" ] && [ "$SOURCE_DB_NAME" != "$DB_NAME" ]; then + USE_SANITIZE=1 +fi + +ORIG_DB_OWNER="$(resolve_db_owner)" +if [ -z "$ORIG_DB_OWNER" ]; then + fail "Nie można ustalić właściciela bazy ${DB_NAME}." + exit 1 +fi + +if [ "$RESTORE_MODE" = "new_db" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Typ przywracania: Przywracanie do nowej bazy" >> "$LOG_FILE" +else + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Typ przywracania: Przywracanie z nadpisaniem" >> "$LOG_FILE" +fi +if [ -n "$SOURCE_DB_NAME" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Baza źródłowa: ${SOURCE_DB_NAME}" >> "$LOG_FILE" +fi +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Baza docelowa: ${DB_NAME}" >> "$LOG_FILE" + +if [ "$PRE_CLEAN" -eq 1 ]; then + preclean_database "$ORIG_DB_OWNER" +fi + +# Jeżeli restore pochodzi z zewnętrznego pliku i baza docelowa jest pusta, +# użyj użytkownika przypisanego do bazy (właściciela), bez użytkownika tymczasowego. +if database_is_empty "$DB_NAME"; then + TARGET_DB_EMPTY=1 +fi +if [ "$SOURCE_KIND" = "file" ] && [ "$TARGET_DB_EMPTY" -eq 1 ] && [ "$USE_OWNER_RESTORE" -eq 0 ]; then + USE_OWNER_RESTORE=1 + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Wykryto pustą bazę docelową i restore z pliku: używam użytkownika przypisanego do bazy (${ORIG_DB_OWNER})." >> "$LOG_FILE" +fi + +if [ "$USE_OWNER_RESTORE" -eq 0 ]; then + TMP_DB_USER="pgtmp_rst_$(printf '%s' "$JOB_ID" | tr -cd 'A-Za-z0-9' | tr '[:upper:]' '[:lower:]' | head -c 18)" + [ -n "$TMP_DB_USER" ] || TMP_DB_USER="pgtmp_rst_$(date +%s)" + TMP_DB_PASS="$(random_tmp_password)" + PASS_ESC="$(sql_escape_literal "$TMP_DB_PASS")" + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tworzenie tymczasowego użytkownika restore ${TMP_DB_USER}" >> "$LOG_FILE" + psql_admin postgres -c "SET password_encryption='scram-sha-256'; DROP ROLE IF EXISTS \"$TMP_DB_USER\"; CREATE ROLE \"$TMP_DB_USER\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOREPLICATION PASSWORD '${PASS_ESC}';" >> "$LOG_FILE" 2>&1 + TMP_DB_ACTIVE=1 + + psql_admin postgres -c "GRANT CONNECT, CREATE, TEMPORARY ON DATABASE \"$DB_NAME\" TO \"$TMP_DB_USER\";" >> "$LOG_FILE" 2>&1 + psql_admin "$DB_NAME" -c "GRANT USAGE, CREATE ON SCHEMA public TO \"$TMP_DB_USER\"; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"$TMP_DB_USER\"; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"$TMP_DB_USER\"; GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO \"$TMP_DB_USER\";" >> "$LOG_FILE" 2>&1 + + if [ -n "$ORIG_DB_OWNER" ] && [ "$ORIG_DB_OWNER" != "$TMP_DB_USER" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tymczasowe przejęcie właściciela bazy ${DB_NAME} przez ${TMP_DB_USER}" >> "$LOG_FILE" + psql_admin postgres -c "ALTER DATABASE \"$DB_NAME\" OWNER TO \"$TMP_DB_USER\";" >> "$LOG_FILE" 2>&1 + DB_OWNER_SWITCHED=1 + fi + + # Restore z plików dump może zawierać operacje wymagające bycia właścicielem + # schematu public (np. COMMENT ON SCHEMA public). Ustaw właściciela na użytkownika + # tymczasowego, a po restore owner wróci przez restore_db_owner(). + psql_admin "$DB_NAME" -c "ALTER SCHEMA public OWNER TO \"$TMP_DB_USER\";" >> "$LOG_FILE" 2>&1 || true +fi + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Przywracanie bazy ${DB_NAME} z pliku ${SOURCE_FILE}" >> "$LOG_FILE" +if [ "$USE_OWNER_RESTORE" -eq 1 ]; then + set +e + if [ "$USE_SANITIZE" -eq 1 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Sanityzacja dumpa: ${SOURCE_DB_NAME} -> ${DB_NAME}" >> "$LOG_FILE" + if [[ "$SOURCE_FILE" == *.gz ]]; then + { printf 'SET ROLE "%s";\n' "$ORIG_DB_OWNER"; gunzip -c "$SOURCE_FILE" | sanitize_pg_dump "$SOURCE_DB_NAME" "$DB_NAME" | sanitize_restore_compat; } | psql_admin "$DB_NAME" >> "$LOG_FILE" 2>&1 + RC=$? + else + { printf 'SET ROLE "%s";\n' "$ORIG_DB_OWNER"; sanitize_pg_dump "$SOURCE_DB_NAME" "$DB_NAME" < "$SOURCE_FILE" | sanitize_restore_compat; } | psql_admin "$DB_NAME" >> "$LOG_FILE" 2>&1 + RC=$? + fi + else + run_restore_as_owner "$ORIG_DB_OWNER" + RC=$? + fi + set -e +else + if [[ "$SOURCE_FILE" == *.gz ]]; then + set +e + if [ "$USE_SANITIZE" -eq 1 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Sanityzacja dumpa: ${SOURCE_DB_NAME} -> ${DB_NAME}" >> "$LOG_FILE" + gunzip -c "$SOURCE_FILE" | sanitize_pg_dump "$SOURCE_DB_NAME" "$DB_NAME" | sanitize_restore_compat | PGPASSWORD="$TMP_DB_PASS" "$PSQL_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$TMP_DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 >> "$LOG_FILE" 2>&1 + RC=$? + else + gunzip -c "$SOURCE_FILE" | sanitize_restore_compat | PGPASSWORD="$TMP_DB_PASS" "$PSQL_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$TMP_DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 >> "$LOG_FILE" 2>&1 + RC=$? + fi + set -e + else + set +e + if [ "$USE_SANITIZE" -eq 1 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Sanityzacja dumpa: ${SOURCE_DB_NAME} -> ${DB_NAME}" >> "$LOG_FILE" + sanitize_pg_dump "$SOURCE_DB_NAME" "$DB_NAME" < "$SOURCE_FILE" | sanitize_restore_compat | PGPASSWORD="$TMP_DB_PASS" "$PSQL_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$TMP_DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 >> "$LOG_FILE" 2>&1 + RC=$? + else + sanitize_restore_compat < "$SOURCE_FILE" | PGPASSWORD="$TMP_DB_PASS" "$PSQL_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$TMP_DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 >> "$LOG_FILE" 2>&1 + RC=$? + fi + set -e + fi +fi + +if [ "$RC" -ne 0 ]; then + fail "Nie udało się przywrócić bazy ${DB_NAME}." + exit 1 +fi + +if [ "$USE_OWNER_RESTORE" -eq 0 ]; then + if ! restore_db_owner; then + fail "Nie udało się przywrócić właściciela bazy ${DB_NAME}." + exit 1 + fi +fi + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Przywracanie zakończone powodzeniem" >> "$LOG_FILE" +RESTORE_OK=1 +exit 0 diff --git a/scripts/setup/adminer_install.sh b/scripts/setup/adminer_install.sh new file mode 100755 index 0000000..f97bdbc --- /dev/null +++ b/scripts/setup/adminer_install.sh @@ -0,0 +1,704 @@ +#!/bin/bash +# ============================================================================= +# adminer_install.sh +# Instalacja i integracja Adminera z pluginem da-postgresql (DirectAdmin) +# +# Założenia bezpieczeństwa: +# - Adminer dostępny przez alias /adminer +# - Tryb public/private odczytywany runtime z /var/www/html/adminer/runtime/config.json +# - Tryb publiczny (ADMINER_PUBLIC=true) pozwala na ręczne logowanie do PostgreSQL +# - Tryb prywatny (ADMINER_PUBLIC=false) wymaga ticketu SSO z pluginu +# - Ticket SSO ma krótki TTL i jest przypięty do User-Agent +# - Logowanie odbywa się tymczasową rolą PostgreSQL tworzoną przez plugin +# ============================================================================= + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +sekcja() { echo -e "\n${BOLD}${CYAN}=== $* ===${NC}\n"; } +die() { echo -e "${RED}[BŁĄD]${NC} $*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Skrypt musi być uruchomiony jako root." + +LOGFILE="/var/log/adminer_install_$(date +%Y%m%d_%H%M%S).log" +exec 1> >(tee >(sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' >> "$LOGFILE")) 2>&1 +info "Pełny log zapisany w: $LOGFILE" + +ADMINER_URL_PATH="/adminer" +ADMINER_VERSION="5.4.2" +ADMINER_FLAVOR="pgsql" +ADMINER_INSTALL_DIR="/var/www/html/adminer" +ADMINER_RUNTIME_DIR="${ADMINER_INSTALL_DIR}/runtime" +ADMINER_TICKETS_DIR="${ADMINER_RUNTIME_DIR}/tickets" +ADMINER_RUNTIME_CONFIG_FILE="${ADMINER_RUNTIME_DIR}/config.json" +ADMINER_CORE_FILE="${ADMINER_INSTALL_DIR}/adminer-core.php" +ADMINER_EXTENSION_FILE="${ADMINER_INSTALL_DIR}/adminer-extension.php" +ADMINER_INDEX_FILE="${ADMINER_INSTALL_DIR}/index.php" +PLUGIN_BUNDLED_ADMINER="/usr/local/directadmin/plugins/da-postgresql/assets/adminer/adminer-${ADMINER_VERSION}-${ADMINER_FLAVOR}.php" +PLUGIN_BUNDLED_ADMINER_SHA256="059505abc2b56487d78bbfbeb9485ed8c6a10fe357f4ddec9fc69b1043bff4af" +PLUGIN_BUNDLED_EXTENSION="/usr/local/directadmin/plugins/da-postgresql/assets/adminer/adminer-extension.php" +PLUGIN_BUNDLED_EXTENSION_SHA256="6e2f098b172838ccb3736506867396dc0954a6383fdd7b7c5e7739ab21baafeb" + +CB_DIR="/usr/local/directadmin/custombuild" +CB_BUILD="${CB_DIR}/build" +APACHE_ALIAS_CUSTOM="${CB_DIR}/custom/ap2/conf/extra/httpd-alias.conf" +APACHE_ALIAS_SOURCE="/etc/httpd/conf/extra/httpd-alias.conf" +APACHE_ALIAS_CONFIGURE="${CB_DIR}/configure/ap2/conf/extra/httpd-alias.conf" + +PLUGIN_SETTINGS="/usr/local/directadmin/plugins/da-postgresql/plugin-settings.conf" +PLUGIN_OWNER="diradmin:diradmin" +ADMINER_PUBLIC_DEFAULT="false" +ADMINER_PUBLIC_MODE="0" + +detect_apache_group() { + local group_name="" + + if [[ -f /etc/httpd/conf/httpd.conf ]]; then + group_name="$(awk 'tolower($1)=="group" {print $2; exit}' /etc/httpd/conf/httpd.conf | tr -d '[:space:]')" + fi + + if [[ -z "$group_name" && -f /etc/apache2/apache2.conf ]]; then + group_name="$(awk 'tolower($1)=="group" {print $2; exit}' /etc/apache2/apache2.conf | tr -d '[:space:]')" + fi + + if [[ -z "$group_name" ]]; then + for candidate in apache www-data nobody; do + if getent group "$candidate" >/dev/null 2>&1; then + group_name="$candidate" + break + fi + done + fi + + [[ -n "$group_name" ]] || die "Nie można wykryć grupy użytkownika Apache." + echo "$group_name" +} + +set_setting() { + local key="$1" + local value="$2" + local file="$3" + + [[ -f "$file" ]] || return 0 + sed -i "/^${key}=/d" "$file" || true + echo "${key}=${value}" >> "$file" +} + +set_setting_if_missing() { + local key="$1" + local value="$2" + local file="$3" + + [[ -f "$file" ]] || return 0 + if grep -q "^${key}=" "$file"; then + return 0 + fi + echo "${key}=${value}" >> "$file" +} + +bool_is_true() { + local v + v="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + case "$v" in + 1|true|yes|y|on|tak|t) return 0 ;; + *) return 1 ;; + esac +} + +read_adminer_public_mode() { + local raw="$ADMINER_PUBLIC_DEFAULT" + if [[ -f "$PLUGIN_SETTINGS" ]]; then + local found + found="$(awk -F= '/^ADMINER_PUBLIC=/{print $2; exit}' "$PLUGIN_SETTINGS" | tr -d '[:space:]')" + if [[ -n "$found" ]]; then + raw="$found" + fi + fi + + if bool_is_true "$raw"; then + ADMINER_PUBLIC_MODE="1" + else + ADMINER_PUBLIC_MODE="0" + fi +} + +write_runtime_public_config() { + local public_json="false" + if [[ "$ADMINER_PUBLIC_MODE" == "1" ]]; then + public_json="true" + fi + + cat > "$ADMINER_RUNTIME_CONFIG_FILE" </dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + return 0 + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + return 0 + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | awk '{print $NF}' + return 0 + fi + + return 1 +} + +render_adminer_wrapper() { + cat > "$ADMINER_INDEX_FILE" <<'PHPEOF' + 0, + 'path' => '/adminer', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + 'httponly' => true, + 'samesite' => 'Lax', +]); +session_start(); + +$now = time(); +$adminerPublic = load_public_mode(); + +if (isset($_GET['logout'])) { + clear_session(); + if (!$adminerPublic) { + render_placeholder('Sesja Adminera zostala zamknieta. Otworz Adminera ponownie z panelu DirectAdmin.'); + } +} + +$auth = null; +$ticketId = ticket_id_from_request(); +if ($ticketId !== '') { + $ticketAccepted = false; + $ticketRejectReason = ''; + $ticketBinding = ''; + $ticket = load_ticket($ticketId); + if ($ticket !== null) { + $ticketBinding = isset($ticket['user_binding']) && is_string($ticket['user_binding']) ? $ticket['user_binding'] : ''; + } + + if ($ticket === null) { + $ticketRejectReason = 'ticket_missing_or_unreadable'; + } elseif (validate_ticket($ticket, $now, $adminerPublic, $ticketRejectReason)) { + $auth = [ + 'host' => 'localhost', + 'port' => (int)($ticket['port'] ?? 5432), + 'user' => (string)($ticket['role'] ?? ''), + 'password' => (string)($ticket['password'] ?? ''), + 'database' => (string)($ticket['database'] ?? ''), + 'expires_at' => (int)($ticket['session_expires_at'] ?? ($now + 900)), + ]; + $_SESSION[DA_ADMINER_SESSION_KEY] = $auth; + @unlink(ticket_path($ticketId)); + clear_ticket_from_request(); + $ticketAccepted = true; + } + + debug_ticket_event($ticketId, $ticketAccepted, $ticketRejectReason, $ticketBinding, current_user_binding()); + + if (!$ticketAccepted && !$adminerPublic) { + render_placeholder('Nieprawidlowy lub wygasly link logowania. Otworz Adminera z poziomu pluginu.'); + } +} + +if ($auth === null) { + $auth = $_SESSION[DA_ADMINER_SESSION_KEY] ?? null; +} +if (!is_array($auth) || empty($auth['user']) || empty($auth['password'])) { + $auth = null; + if (!$adminerPublic) { + render_placeholder(); + } +} + +if ($auth !== null) { + $expiresAt = (int)($auth['expires_at'] ?? 0); + if ($expiresAt <= 0 || $expiresAt < $now) { + clear_session(); + $auth = null; + if (!$adminerPublic) { + render_placeholder('Sesja Adminera wygasla. Otworz Adminera ponownie z poziomu pluginu.'); + } + } +} + +if (has_forbidden_driver_params()) { + clear_session(); + http_response_code(403); + render_placeholder('Dozwolony jest tylko sterownik PostgreSQL.'); +} + +$_GET['pgsql'] = 'localhost'; + +if ($auth !== null) { + bootstrap_adminer_session($auth); + unset($_POST['auth']); +} else { + normalize_post_auth(); +} +$GLOBALS['da_adminer_sso_auth'] = $auth; + +require DA_ADMINER_EXTENSION_FILE; +require DA_ADMINER_CORE_FILE; +exit; + +function ticket_id_from_request(): string +{ + $ticket = $_GET['ticket'] ?? ''; + if (!is_string($ticket)) { + return ''; + } + $ticket = trim($ticket); + if ($ticket === '' || !preg_match('/^[A-Za-z0-9_-]{20,128}$/', $ticket)) { + return ''; + } + return $ticket; +} + +function ticket_path(string $ticketId): string +{ + return DA_ADMINER_TICKETS_DIR . '/' . $ticketId . '.json'; +} + +function clear_ticket_from_request(): void +{ + unset($_GET['ticket']); + + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (!is_string($uri) || $uri === '') { + return; + } + + $parts = parse_url($uri); + if (!is_array($parts)) { + return; + } + + $path = isset($parts['path']) && is_string($parts['path']) ? $parts['path'] : '/adminer'; + $query = []; + if (isset($parts['query']) && is_string($parts['query']) && $parts['query'] !== '') { + parse_str($parts['query'], $query); + if (is_array($query)) { + unset($query['ticket']); + } else { + $query = []; + } + } + + $newQuery = http_build_query($query); + $_SERVER['REQUEST_URI'] = $path . ($newQuery !== '' ? ('?' . $newQuery) : ''); +} + +function load_ticket(string $ticketId): ?array +{ + $path = ticket_path($ticketId); + if (!is_file($path) || !is_readable($path)) { + return null; + } + + $raw = @file_get_contents($path); + if (!is_string($raw) || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; +} + +function load_public_mode(): bool +{ + $default = (DA_ADMINER_PUBLIC_DEFAULT === 1); + if (!is_file(DA_ADMINER_CONFIG_FILE) || !is_readable(DA_ADMINER_CONFIG_FILE)) { + return $default; + } + + $raw = @file_get_contents(DA_ADMINER_CONFIG_FILE); + if (!is_string($raw) || $raw === '') { + return $default; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded) || !array_key_exists('adminer_public', $decoded)) { + return $default; + } + + return parse_bool_value($decoded['adminer_public'], $default); +} + +function parse_bool_value($value, bool $default = false): bool +{ + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return ((int)$value) !== 0; + } + if (!is_string($value)) { + return $default; + } + + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'yes', 'y', 'on', 'tak', 't'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'n', 'off', 'nie'], true)) { + return false; + } + + return $default; +} + +function validate_ticket(array $ticket, int $now, bool $adminerPublic, string &$reason = ''): bool +{ + $required = ['role', 'password', 'database', 'host', 'port', 'expires_at', 'session_expires_at', 'user_binding']; + foreach ($required as $key) { + if (!array_key_exists($key, $ticket)) { + $reason = 'missing_key:' . $key; + return false; + } + } + + $expiresAt = is_numeric($ticket['expires_at']) ? (int)$ticket['expires_at'] : 0; + if ($expiresAt < $now) { + $reason = 'ticket_expired'; + return false; + } + + $sessionExpiresAt = is_numeric($ticket['session_expires_at']) ? (int)$ticket['session_expires_at'] : 0; + if ($sessionExpiresAt < $now) { + $reason = 'session_expired'; + return false; + } + + $role = (string)$ticket['role']; + if ($role === '' || !preg_match('/^[a-z0-9_]{1,63}$/', $role)) { + $reason = 'invalid_role'; + return false; + } + + $binding = (string)$ticket['user_binding']; + if (!is_binding_valid($binding, current_user_binding(), $adminerPublic)) { + $reason = 'binding_mismatch'; + return false; + } + + $reason = 'ok'; + return true; +} + +function current_user_binding(): string +{ + $ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? ''); + return hash('sha256', $ua); +} + +function is_binding_valid(string $ticketBinding, string $currentBinding, bool $adminerPublic): bool +{ + if ($adminerPublic) { + return true; + } + + if ($ticketBinding === '') { + return false; + } + + if (hash_equals($ticketBinding, $currentBinding)) { + return true; + } + + // Kompatybilność: starsze ticket-y mogły być wystawione bez HTTP_USER_AGENT po stronie DA. + $legacyNoUaBinding = hash('sha256', ''); + if (hash_equals($ticketBinding, $legacyNoUaBinding)) { + return true; + } + + return false; +} + +function debug_ticket_event( + string $ticketId, + bool $accepted, + string $reason, + string $ticketBinding, + string $currentBinding +): void { + $line = sprintf( + "[%s] ticket=%s accepted=%s reason=%s ticket_binding=%s current_binding=%s remote=%s ua=%s\n", + date('c'), + $ticketId, + $accepted ? '1' : '0', + $reason !== '' ? $reason : 'n/a', + $ticketBinding !== '' ? $ticketBinding : '-', + $currentBinding !== '' ? $currentBinding : '-', + (string)($_SERVER['REMOTE_ADDR'] ?? '-'), + (string)($_SERVER['HTTP_USER_AGENT'] ?? '-') + ); + + @error_log($line, 3, '/tmp/da_adminer_wrapper_debug.log'); +} + +function has_forbidden_driver_params(): bool +{ + $forbidden = ['server', 'sqlite', 'mongo', 'mssql', 'oracle', 'elastic', 'clickhouse']; + foreach ($forbidden as $key) { + if (isset($_GET[$key])) { + return true; + } + } + return false; +} + +function bootstrap_adminer_session(array $auth): void +{ + $driver = 'pgsql'; + $server = 'localhost'; + $user = trim((string)($auth['user'] ?? '')); + $password = (string)($auth['password'] ?? ''); + $database = trim((string)($auth['database'] ?? '')); + + if ($user === '' || $password === '') { + return; + } + + $_GET[$driver] = $server; + $_GET['username'] = $user; + if ($database !== '' && (!isset($_GET['db']) || !is_string($_GET['db']) || $_GET['db'] === '')) { + $_GET['db'] = $database; + } + + $_SESSION['pwds'][$driver][$server][$user] = $password; + if ($database !== '') { + $_SESSION['db'][$driver][$server][$user][$database] = true; + } +} + +function normalize_post_auth(): void +{ + if (!isset($_POST['auth']) || !is_array($_POST['auth'])) { + return; + } + + $_POST['auth']['driver'] = 'pgsql'; + $_POST['auth']['server'] = 'localhost'; +} + +function clear_session(): void +{ + $_SESSION = []; + if (session_status() === PHP_SESSION_ACTIVE) { + session_unset(); + session_destroy(); + } +} + +function render_placeholder(string $extra = ''): void +{ + http_response_code(200); + $message = 'Dostep do Adminera jest mozliwy wylacznie z poziomu panelu DirectAdmin.'; + echo ''; + echo 'Adminer - DirectAdmin'; + echo ''; + echo '

    Adminer

    ' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '

    '; + if ($extra !== '') { + echo '

    ' . htmlspecialchars($extra, ENT_QUOTES, 'UTF-8') . '

    '; + } + echo '

    W panelu pluginu PostgreSQL kliknij przycisk "Adminer", aby otworzyc sesje SSO.

    '; + exit; +} +PHPEOF + + sed -i "s/__ADMINER_PUBLIC_MODE__/${ADMINER_PUBLIC_MODE}/g" "$ADMINER_INDEX_FILE" +} + +configure_apache_alias() { + [[ -d "$CB_DIR" ]] || die "Brak katalogu CustomBuild: ${CB_DIR}" + mkdir -p "$(dirname "$APACHE_ALIAS_CUSTOM")" + + if [[ ! -f "$APACHE_ALIAS_CUSTOM" ]]; then + if [[ -f "$APACHE_ALIAS_SOURCE" ]]; then + cp -p "$APACHE_ALIAS_SOURCE" "$APACHE_ALIAS_CUSTOM" + elif [[ -f "$APACHE_ALIAS_CONFIGURE" ]]; then + cp -p "$APACHE_ALIAS_CONFIGURE" "$APACHE_ALIAS_CUSTOM" + else + warn "Nie znaleziono źródłowego httpd-alias.conf – tworzę pusty custom template." + : > "$APACHE_ALIAS_CUSTOM" + fi + fi + + local tmp_file + tmp_file="$(mktemp)" + awk ' + BEGIN { skip = 0 } + /^# BEGIN DA_POSTGRESQL_ADMINER$/ { skip = 1; next } + /^# END DA_POSTGRESQL_ADMINER$/ { skip = 0; next } + skip == 0 { print } + ' "$APACHE_ALIAS_CUSTOM" > "$tmp_file" + + cat >> "$tmp_file" < + AllowOverride None + Options -Indexes + Require all granted + + Require all denied + + + + + AllowOverride None + Options -Indexes + Require all denied + +# END DA_POSTGRESQL_ADMINER +EOF + + mv "$tmp_file" "$APACHE_ALIAS_CUSTOM" + chmod 644 "$APACHE_ALIAS_CUSTOM" +} + +sekcja "Weryfikacja środowiska" +command -v php >/dev/null 2>&1 || die "Brak polecenia php." +id diradmin >/dev/null 2>&1 || die "Brak użytkownika diradmin." + +APACHE_GROUP="$(detect_apache_group)" +info "Wykryta grupa Apache: ${APACHE_GROUP}" +read_adminer_public_mode +if [[ "$ADMINER_PUBLIC_MODE" == "1" ]]; then + info "Tryb ADMINER_PUBLIC: true (bezpośredni dostęp do /adminer bez SSO)." +else + info "Tryb ADMINER_PUBLIC: false (dostęp wyłącznie przez SSO z pluginu)." +fi + +sekcja "Instalacja z bundla Adminera ${ADMINER_VERSION}-${ADMINER_FLAVOR}" +mkdir -p "$ADMINER_INSTALL_DIR" +[[ -f "$PLUGIN_BUNDLED_ADMINER" ]] || die "Brak zbundlowanego pliku Adminera: ${PLUGIN_BUNDLED_ADMINER}" +[[ -f "$PLUGIN_BUNDLED_EXTENSION" ]] || die "Brak zbundlowanego rozszerzenia Adminera: ${PLUGIN_BUNDLED_EXTENSION}" + +EXPECTED_SUM="$PLUGIN_BUNDLED_ADMINER_SHA256" +ACTUAL_SUM="$(sha256_file "$PLUGIN_BUNDLED_ADMINER" || true)" +[[ -n "$ACTUAL_SUM" ]] || die "Nie można policzyć SHA256 dla ${PLUGIN_BUNDLED_ADMINER}." +[[ "$ACTUAL_SUM" == "$EXPECTED_SUM" ]] \ + || die "Niezgodna suma SHA256 dla zbundlowanego Adminera. Oczekiwano: ${EXPECTED_SUM}, otrzymano: ${ACTUAL_SUM}" + +cp -f "$PLUGIN_BUNDLED_ADMINER" "$ADMINER_CORE_FILE" +info "Adminer ${ADMINER_VERSION}-${ADMINER_FLAVOR} skopiowany do: ${ADMINER_CORE_FILE}" + +EXPECTED_EXTENSION_SUM="$PLUGIN_BUNDLED_EXTENSION_SHA256" +ACTUAL_EXTENSION_SUM="$(sha256_file "$PLUGIN_BUNDLED_EXTENSION" || true)" +[[ -n "$ACTUAL_EXTENSION_SUM" ]] || die "Nie można policzyć SHA256 dla ${PLUGIN_BUNDLED_EXTENSION}." +[[ "$ACTUAL_EXTENSION_SUM" == "$EXPECTED_EXTENSION_SUM" ]] \ + || die "Niezgodna suma SHA256 dla zbundlowanego rozszerzenia Adminera. Oczekiwano: ${EXPECTED_EXTENSION_SUM}, otrzymano: ${ACTUAL_EXTENSION_SUM}" + +cp -f "$PLUGIN_BUNDLED_EXTENSION" "$ADMINER_EXTENSION_FILE" +info "Rozszerzenie Adminera skopiowane do: ${ADMINER_EXTENSION_FILE}" + +sekcja "Konfiguracja wrappera bezpieczeństwa" +mkdir -p "$ADMINER_RUNTIME_DIR" "$ADMINER_TICKETS_DIR" +render_adminer_wrapper + +chown root:root "$ADMINER_INSTALL_DIR" +chmod 755 "$ADMINER_INSTALL_DIR" + +chown root:root "$ADMINER_CORE_FILE" "$ADMINER_EXTENSION_FILE" "$ADMINER_INDEX_FILE" +chmod 644 "$ADMINER_CORE_FILE" "$ADMINER_EXTENSION_FILE" "$ADMINER_INDEX_FILE" + +chown diradmin:"$APACHE_GROUP" "$ADMINER_RUNTIME_DIR" "$ADMINER_TICKETS_DIR" +chmod 711 "$ADMINER_RUNTIME_DIR" +chmod 2733 "$ADMINER_TICKETS_DIR" + +write_runtime_public_config +chown diradmin:"$APACHE_GROUP" "$ADMINER_RUNTIME_CONFIG_FILE" +chmod 644 "$ADMINER_RUNTIME_CONFIG_FILE" + +if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + warn "Wykryto SELinux: ustawiam kontekst zapisu runtime dla Apache." + if command -v chcon >/dev/null 2>&1; then + chcon -R -t httpd_sys_rw_content_t "$ADMINER_RUNTIME_DIR" || warn "Nie udało się ustawić kontekstu SELinux dla runtime." + chcon -t httpd_sys_content_t "$ADMINER_CORE_FILE" "$ADMINER_EXTENSION_FILE" "$ADMINER_INDEX_FILE" || warn "Nie udało się ustawić kontekstu SELinux dla plików PHP." + else + warn "Brak chcon - ustaw kontekst SELinux ręcznie." + fi +fi + +sekcja "Konfiguracja aliasu /adminer (DirectAdmin CustomBuild)" +configure_apache_alias + +if [[ -x "$CB_BUILD" ]]; then + (cd "$CB_DIR" && ./build rewrite_confs) \ + || die "rewrite_confs zakończył się błędem." + info "rewrite_confs wykonane pomyślnie." +else + warn "Brak ${CB_BUILD} - uruchom ręcznie: cd ${CB_DIR} && ./build rewrite_confs" +fi + +if systemctl is-active --quiet httpd 2>/dev/null; then + systemctl reload httpd || warn "Nie udało się przeładować httpd." +fi + +sekcja "Aktualizacja ustawień pluginu" +if [[ -f "$PLUGIN_SETTINGS" ]]; then + set_setting "ENABLE_ADMINER" "true" "$PLUGIN_SETTINGS" + set_setting_if_missing "ADMINER_PUBLIC" "false" "$PLUGIN_SETTINGS" + set_setting_if_missing "ADMINER_PUBLIC_BASE_URL" "" "$PLUGIN_SETTINGS" + set_setting "ADMINER_URL_PATH" "$ADMINER_URL_PATH" "$PLUGIN_SETTINGS" + set_setting "ADMINER_SSO_DIR" "$ADMINER_RUNTIME_DIR" "$PLUGIN_SETTINGS" + set_setting "ADMINER_TICKET_TTL" "120" "$PLUGIN_SETTINGS" + set_setting "ADMINER_SESSION_TTL" "900" "$PLUGIN_SETTINGS" + set_setting "ADMINER_ROLE_TTL" "1200" "$PLUGIN_SETTINGS" + chown "$PLUGIN_OWNER" "$PLUGIN_SETTINGS" 2>/dev/null || true + chmod 640 "$PLUGIN_SETTINGS" 2>/dev/null || true + info "Zaktualizowano: ${PLUGIN_SETTINGS}" +else + warn "Nie znaleziono pliku ${PLUGIN_SETTINGS} - ustawienia pluginu pominięte." +fi + +sekcja "Instalacja zakończona" +echo "Adminer URL: ${ADMINER_URL_PATH}" +echo "Katalog: ${ADMINER_INSTALL_DIR}" +echo "Runtime ticketów: ${ADMINER_TICKETS_DIR}" +echo "Runtime config: ${ADMINER_RUNTIME_CONFIG_FILE}" +echo "Alias template: ${APACHE_ALIAS_CUSTOM}" +echo "Log: ${LOGFILE}" diff --git a/scripts/setup/dbrestore.sh b/scripts/setup/dbrestore.sh new file mode 100755 index 0000000..2e72932 --- /dev/null +++ b/scripts/setup/dbrestore.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# ============================================================================= +# dbrestore.sh +# Przywracanie pojedynczej bazy PostgreSQL z pliku .sql lub .sql.gz +# wykonanego przez postgres_backup.sh +# ============================================================================= + +CREDENTIALS_FILE="/usr/local/directadmin/conf/postgresql.conf" + +# Kolory tylko na terminalu +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; NC='' +fi + +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +info() { echo -e " $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +die() { echo -e "${RED}[BŁĄD]${NC} $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# Sposób użycia +# --------------------------------------------------------------------------- +usage() { + cat < + +Opis: + Przywraca bazę danych z pliku wykonanego przez postgres_backup.sh. + Obsługuje pliki nieskompresowane (.sql) i skompresowane (.sql.gz). + Jeśli docelowa baza istnieje, zostanie usunięta i odtworzona od nowa. + +Przykłady: + $(basename "$0") /home/admin/postgres_backup/19-02-2026/sklep.sql + $(basename "$0") /home/admin/postgres_backup/19-02-2026/sklep.sql.gz + +Uwaga: + Plik poświadczeń: ${CREDENTIALS_FILE} + Baza 'postgres' jest przywracana przez połączenie do 'template1', + pozostałe bazy – przez połączenie do 'postgres'. + +EOF +} + +# --------------------------------------------------------------------------- +# Argumenty +# --------------------------------------------------------------------------- +if [[ $# -eq 0 ]]; then + usage + exit 0 +fi + +FILE="$1" + +[[ -f "$FILE" ]] || die "Plik nie istnieje: ${FILE}" + +case "$FILE" in + *.sql.gz|*.sql) ;; + *) die "Nieobsługiwany format pliku. Oczekiwany: .sql lub .sql.gz" ;; +esac + +# --------------------------------------------------------------------------- +# Poświadczenia +# --------------------------------------------------------------------------- +[[ -f "$CREDENTIALS_FILE" ]] \ + || die "Brak pliku poświadczeń: ${CREDENTIALS_FILE}" + +_perms=$(stat -c '%a' "$CREDENTIALS_FILE") +[[ "$_perms" == "600" || "$_perms" == "400" ]] \ + || die "Zbyt otwarte uprawnienia na ${CREDENTIALS_FILE} (${_perms}). Wymagane: 600 lub 400." + +_line=$(head -1 "$CREDENTIALS_FILE") +PGHOST=$(echo "$_line" | cut -d: -f1) +PGPORT=$(echo "$_line" | cut -d: -f2) +PGUSER=$(echo "$_line" | cut -d: -f4) + +[[ -n "$PGHOST" && -n "$PGPORT" && -n "$PGUSER" ]] \ + || die "Nieprawidłowy format pliku poświadczeń. Oczekiwany: host:port:database:user:password" + +export PGPASSFILE="$CREDENTIALS_FILE" + +# --------------------------------------------------------------------------- +# Ustal bazę inicjalną (nie można przywracać 'postgres' będąc do niej podłączonym) +# --------------------------------------------------------------------------- +DB_NAME=$(basename "$FILE" | sed 's/\.sql\.gz$//; s/\.sql$//') + +if [[ "$DB_NAME" == "postgres" ]]; then + INIT_DB="template1" +else + INIT_DB="postgres" +fi + +# --------------------------------------------------------------------------- +# Przywracanie +# --------------------------------------------------------------------------- +echo "" +ok "Plik: ${FILE}" +info "Baza: ${DB_NAME}" +info "Serwer: ${PGHOST}:${PGPORT} Użytkownik: ${PGUSER}" +echo "" + +case "$FILE" in + *.sql.gz) + gunzip -c "$FILE" \ + | psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password -d "$INIT_DB" + RC=${PIPESTATUS[1]} + ;; + *.sql) + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password -d "$INIT_DB" -f "$FILE" + RC=$? + ;; +esac + +echo "" +if [[ $RC -eq 0 ]]; then + ok "Przywracanie bazy '${DB_NAME}' zakończone pomyślnie." +else + warn "Przywracanie bazy '${DB_NAME}' zakończone z ostrzeżeniami lub błędami (kod: ${RC})." + warn "Sprawdź powyższy output w celu identyfikacji problemów." +fi + +exit $RC diff --git a/scripts/setup/pg_hba_sync.sh b/scripts/setup/pg_hba_sync.sh new file mode 100755 index 0000000..6754ba3 --- /dev/null +++ b/scripts/setup/pg_hba_sync.sh @@ -0,0 +1,636 @@ +#!/bin/bash +set -euo pipefail + +CREDS_FILE="/usr/local/directadmin/conf/postgresql.conf" +PLUGIN_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PLUGIN_SETTINGS_FILE="$PLUGIN_ROOT/plugin-settings.conf" + +PGHOST_VAL="" +PGPORT_VAL="5432" +PGUSER_VAL="postgres" +PGPASSWORD_VAL="" +PG_VERSION_NUM="" + +ALLOW_REMOTE_HOSTS="1" +RESTART_POSTGRESQL="0" + +HBA_MANAGED_BLOCK_BEGIN="# DA_POSTGRESQL_PLUGIN_HBA_BEGIN" +HBA_MANAGED_BLOCK_END="# DA_POSTGRESQL_PLUGIN_HBA_END" +POSTGRESQL_CONF="" +HBA_FILE="" + +CSF_ALLOW_FILE="/etc/csf/csf.allow" +CSF_INCLUDE_FILE="/etc/csf.allow.postgres" + +PG_CONF_MARKER_BEGIN="# DA_POSTGRESQL_PLUGIN_BEGIN" +PG_CONF_MARKER_END="# DA_POSTGRESQL_PLUGIN_END" + +declare -A REMOTE_IP_NOTES=() + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --restart-postgresql) + RESTART_POSTGRESQL="1" + ;; + *) + echo "Nieznany argument: $1" >&2 + exit 2 + ;; + esac + shift + done +} + +find_psql() { + if command -v psql >/dev/null 2>&1; then + command -v psql + return + fi + + local candidate + candidate="$(ls -1d /usr/pgsql-*/bin/psql 2>/dev/null | sort -V | tail -n1 || true)" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + echo "$candidate" + return + fi + + echo "" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +strip_inline_comment() { + local value="$1" + value="${value%%#*}" + trim "$value" +} + +unquote() { + local value + value="$(trim "$1")" + + if [ "${#value}" -ge 2 ] && [ "${value:0:1}" = "'" ] && [ "${value: -1}" = "'" ]; then + value="${value:1:${#value}-2}" + elif [ "${#value}" -ge 2 ] && [ "${value:0:1}" = '"' ] && [ "${value: -1}" = '"' ]; then + value="${value:1:${#value}-2}" + fi + + printf '%s' "$value" +} + +bool_to_01() { + local raw + raw="$(echo "$1" | tr '[:upper:]' '[:lower:]')" + raw="$(trim "$raw")" + case "$raw" in + 1|true|yes|y|on|checked) + echo "1" + ;; + *) + echo "0" + ;; + esac +} + +is_ipv4_strict() { + local ip="$1" + if [[ ! "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + return 1 + fi + + local IFS='.' + local o1 o2 o3 o4 + read -r o1 o2 o3 o4 <<< "$ip" + for octet in "$o1" "$o2" "$o3" "$o4"; do + if [ "$octet" -gt 255 ]; then + return 1 + fi + if [[ "$octet" =~ ^0[0-9]+$ ]]; then + return 1 + fi + done + + return 0 +} + +is_ipv4_cidr_strict() { + local cidr="$1" + if [[ ! "$cidr" =~ ^([^/]+)/([0-9]{1,2})$ ]]; then + return 1 + fi + + local ip="${BASH_REMATCH[1]}" + local prefix="${BASH_REMATCH[2]}" + is_ipv4_strict "$ip" || return 1 + [ "$prefix" -ge 0 ] && [ "$prefix" -le 32 ] +} + +skip_csf_remote_pattern() { + [ "$1" = "0.0.0.0/0" ] +} + +sanitize_note() { + local note="$1" + note="${note//$'\r'/ }" + note="${note//$'\n'/ }" + note="${note//$'\t'/ }" + note="${note//#/ }" + note="$(echo "$note" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')" + note="${note:0:180}" + printf '%s' "$note" +} + +load_plugin_settings() { + local plain_allow="" + local legacy_allow="" + + if [ ! -r "$PLUGIN_SETTINGS_FILE" ]; then + ALLOW_REMOTE_HOSTS="1" + return + fi + + while IFS= read -r raw_line || [ -n "$raw_line" ]; do + raw_line="$(trim "$raw_line")" + [ -n "$raw_line" ] || continue + [[ "$raw_line" =~ ^# ]] && continue + [[ "$raw_line" == *"="* ]] || continue + + local key="${raw_line%%=*}" + local value="${raw_line#*=}" + key="$(trim "$key")" + value="$(unquote "$(strip_inline_comment "$value")")" + + case "$key" in + allow_remote_hosts) + plain_allow="$value" + ;; + PG_PLUGIN_ALLOW_REMOTE_HOSTS) + legacy_allow="$value" + ;; + esac + done < "$PLUGIN_SETTINGS_FILE" + + if [ -n "$plain_allow" ]; then + ALLOW_REMOTE_HOSTS="$(bool_to_01 "$plain_allow")" + elif [ -n "$legacy_allow" ]; then + ALLOW_REMOTE_HOSTS="$(bool_to_01 "$legacy_allow")" + else + ALLOW_REMOTE_HOSTS="1" + fi +} + +load_credentials() { + [ -r "$CREDS_FILE" ] || { + echo "Brak pliku poświadczeń: $CREDS_FILE" >&2 + exit 1 + } + + local line + line="$(grep -Ev '^[[:space:]]*($|#)' "$CREDS_FILE" | head -n1 || true)" + [ -n "$line" ] || { + echo "Pusty plik poświadczeń: $CREDS_FILE" >&2 + exit 1 + } + + if [[ "$line" == *=* ]]; then + while IFS='=' read -r key value; do + key="$(echo "$key" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]')" + value="$(trim "$value")" + value="$(unquote "$value")" + case "$key" in + PG_HOST) PGHOST_VAL="$value" ;; + PG_PORT) PGPORT_VAL="$value" ;; + PG_USER) PGUSER_VAL="$value" ;; + PG_PASSWORD) PGPASSWORD_VAL="$value" ;; + esac + done < <(grep -Ev '^[[:space:]]*($|#)' "$CREDS_FILE") + else + local rest dbname + PGHOST_VAL="${line%%:*}" + rest="${line#*:}" + PGPORT_VAL="${rest%%:*}" + rest="${rest#*:}" + dbname="${rest%%:*}" + rest="${rest#*:}" + PGUSER_VAL="${rest%%:*}" + PGPASSWORD_VAL="${rest#*:}" + + [ -n "$dbname" ] || dbname="postgres" + fi + + [ -n "$PGHOST_VAL" ] || PGHOST_VAL="localhost" + [ -n "$PGPORT_VAL" ] || PGPORT_VAL="5432" + [ -n "$PGUSER_VAL" ] || PGUSER_VAL="postgres" + [ -n "$PGPASSWORD_VAL" ] || { + echo "Brak hasła PostgreSQL w $CREDS_FILE" >&2 + exit 1 + } +} + +psql_exec() { + PGPASSWORD="$PGPASSWORD_VAL" "$PSQL_BIN" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$PGUSER_VAL" -d postgres -v ON_ERROR_STOP=1 -At -F $'\t' "$@" +} + +load_server_version_num() { + PG_VERSION_NUM="$(psql_exec -c 'SHOW server_version_num;' | tr -d '[:space:]')" + [[ "$PG_VERSION_NUM" =~ ^[0-9]+$ ]] || { + echo "Nieprawidłowy server_version_num: ${PG_VERSION_NUM}" >&2 + exit 1 + } +} + +cleanup_hba_managed_entries() { + local hba_file="$1" + local tmp_file + tmp_file="$(mktemp)" + + awk -v begin="$HBA_MANAGED_BLOCK_BEGIN" -v end="$HBA_MANAGED_BLOCK_END" ' + $0 == begin {skip=1; next} + $0 == end {skip=0; next} + skip == 1 {next} + { + lower = tolower($0) + if (lower ~ /^[[:space:]]*include([[:space:]]|_if_exists)/ && lower ~ /pg_hba_da_plugin\.conf/) { + next + } + print + } + ' "$hba_file" > "$tmp_file" + + install -m 600 "$tmp_file" "$hba_file" + chown postgres:postgres "$hba_file" 2>/dev/null || true + rm -f "$tmp_file" +} + +write_hba_inline_block() { + local hba_file="$1" + local include_file="$2" + local tmp_file + tmp_file="$(mktemp)" + + cp -p "$hba_file" "${hba_file}.bak.$(date +%s)" + cat "$hba_file" > "$tmp_file" + + { + echo "" + echo "$HBA_MANAGED_BLOCK_BEGIN" + echo "# Managed by DirectAdmin PostgreSQL plugin (inline mode)." + cat "$include_file" + echo "$HBA_MANAGED_BLOCK_END" + } >> "$tmp_file" + + install -m 600 "$tmp_file" "$hba_file" + chown postgres:postgres "$hba_file" 2>/dev/null || true + rm -f "$tmp_file" +} + +append_remote_ip_note() { + local ip="$1" + local note="$2" + + if [ -z "${REMOTE_IP_NOTES[$ip]+x}" ]; then + REMOTE_IP_NOTES["$ip"]="$note" + return + fi + + if [ -z "$note" ]; then + return + fi + + local existing="${REMOTE_IP_NOTES[$ip]}" + if [ -z "$existing" ]; then + REMOTE_IP_NOTES["$ip"]="$note" + return + fi + + if [[ "$existing" != *"$note"* ]]; then + REMOTE_IP_NOTES["$ip"]="$existing | $note" + fi +} + +expand_hba_addresses() { + local host="$1" + case "$host" in + localhost) + echo "127.0.0.1/32" + echo "::1/128" + return + ;; + 127.0.0.1) + echo "127.0.0.1/32" + return + ;; + ::1) + echo "::1/128" + return + ;; + esac + + if is_ipv4_strict "$host"; then + echo "$host/32" + return + fi + + if is_ipv4_cidr_strict "$host"; then + echo "$host" + fi +} + +build_hba_include() { + local include_file="$1" + local tmp_file + tmp_file="$(mktemp)" + + { + echo "# Auto-generated by DirectAdmin PostgreSQL plugin" + echo "# Generated at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + echo "# Method: scram-sha-256" + } > "$tmp_file" + + local sql_query + sql_query="SELECT d.datname, h.role_name, h.host_pattern, COALESCE(h.note, '') +FROM da_plugin.access_hosts h +JOIN pg_roles r ON r.rolname = h.role_name +JOIN pg_database d + ON d.datistemplate = false + AND ( + (COALESCE(h.db_name, '') <> '' AND d.datname = h.db_name) + OR + (COALESCE(h.db_name, '') = '' AND left(d.datname, length(h.da_user) + 1) = h.da_user || '_') + ) +WHERE COALESCE(h.db_name, '') <> '' + OR has_database_privilege(h.role_name, d.datname, 'CONNECT') +ORDER BY d.datname, h.role_name, h.host_pattern;" + + local query_output + query_output="$(psql_exec -c "$sql_query")" || { + local status=$? + rm -f "$tmp_file" + echo "Nie udało się odczytać rekordów hostów z da_plugin.access_hosts." >&2 + return "$status" + } + + local access_host_rows="0" + local generated_rules="0" + + while IFS=$'\t' read -r db role host note; do + [ -n "$db" ] || continue + [ -n "$role" ] || continue + [ -n "$host" ] || continue + access_host_rows=$((access_host_rows + 1)) + + local host_note + host_note="$(sanitize_note "$note")" + local include_host="0" + local is_remote="0" + + case "$host" in + localhost|127.0.0.1|::1) + include_host="1" + ;; + *) + if [ "$ALLOW_REMOTE_HOSTS" = "1" ] && { is_ipv4_strict "$host" || is_ipv4_cidr_strict "$host"; }; then + include_host="1" + is_remote="1" + fi + ;; + esac + + [ "$include_host" = "1" ] || continue + + if [ "$is_remote" = "1" ]; then + append_remote_ip_note "$host" "$host_note" + fi + + local note_label="$host_note" + [ -n "$note_label" ] || note_label="(brak komentarza)" + printf '# role=%s db=%s host=%s comment=%s\n' "$role" "$db" "$host" "$note_label" >> "$tmp_file" + + while IFS= read -r addr; do + [ -n "$addr" ] || continue + printf 'host\t%s\t%s\t%s\tscram-sha-256\n' "$db" "$role" "$addr" >> "$tmp_file" + generated_rules=$((generated_rules + 1)) + done < <(expand_hba_addresses "$host") + done <<< "$query_output" + + install -m 600 "$tmp_file" "$include_file" + chown postgres:postgres "$include_file" 2>/dev/null || true + rm -f "$tmp_file" + + echo "Read access host rows: $access_host_rows" + echo "Generated pg_hba host rules: $generated_rules" +} + +sorted_remote_ips() { + if [ "${#REMOTE_IP_NOTES[@]}" -eq 0 ]; then + return 0 + fi + + printf '%s\n' "${!REMOTE_IP_NOTES[@]}" | sort -V +} + +write_postgresql_conf_block() { + local conf_file="$1" + local listen_value="localhost" + local has_remote_ips="0" + + if [ "$ALLOW_REMOTE_HOSTS" = "1" ]; then + listen_value="*" + fi + + if [ "$ALLOW_REMOTE_HOSTS" = "1" ] && [ "${#REMOTE_IP_NOTES[@]}" -gt 0 ]; then + has_remote_ips="1" + fi + + local tmp_file + tmp_file="$(mktemp)" + + awk -v begin="$PG_CONF_MARKER_BEGIN" -v end="$PG_CONF_MARKER_END" ' + $0 == begin {skip=1; next} + $0 == end {skip=0; next} + skip != 1 {print} + ' "$conf_file" > "$tmp_file" + + { + echo "" + echo "$PG_CONF_MARKER_BEGIN" + echo "# Managed by DirectAdmin PostgreSQL plugin." + echo "listen_addresses = '$listen_value'" + echo "# Zdefiniowane hosty IPv4 z pluginu:" + if [ "$has_remote_ips" = "1" ]; then + while IFS= read -r ip; do + [ -n "$ip" ] || continue + local note="${REMOTE_IP_NOTES[$ip]}" + if [ -n "$note" ]; then + printf '# %s ; %s\n' "$ip" "$note" + else + printf '# %s\n' "$ip" + fi + done < <(sorted_remote_ips) + else + echo "# brak zdalnych hostów" + fi + echo "$PG_CONF_MARKER_END" + } >> "$tmp_file" + + install -m 600 "$tmp_file" "$conf_file" + chown postgres:postgres "$conf_file" 2>/dev/null || true + rm -f "$tmp_file" +} + +ensure_csf_include_directive() { + local allow_file="$1" + local include_file="$2" + + [ -f "$allow_file" ] || return 1 + + local include_line="Include $include_file" + if ! grep -Fxq "$include_line" "$allow_file"; then + echo "$include_line" >> "$allow_file" + fi + return 0 +} + +write_csf_include_file() { + local include_file="$1" + local tmp_file + tmp_file="$(mktemp)" + + { + echo "# Auto-generated by DirectAdmin PostgreSQL plugin" + echo "# Generated at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + } > "$tmp_file" + + if [ "$ALLOW_REMOTE_HOSTS" = "1" ] && [ "${#REMOTE_IP_NOTES[@]}" -gt 0 ]; then + while IFS= read -r ip; do + [ -n "$ip" ] || continue + if skip_csf_remote_pattern "$ip"; then + continue + fi + local note="${REMOTE_IP_NOTES[$ip]}" + if [ -n "$note" ]; then + printf '%s # DA PostgreSQL: %s\n' "$ip" "$note" >> "$tmp_file" + else + printf '%s # DA PostgreSQL\n' "$ip" >> "$tmp_file" + fi + done < <(sorted_remote_ips) + fi + + install -m 600 "$tmp_file" "$include_file" + chown root:root "$include_file" 2>/dev/null || true + rm -f "$tmp_file" +} + +reload_postgres_conf() { + psql_exec -c 'SELECT pg_reload_conf();' >/dev/null +} + +restart_postgresql_service() { + if ! command -v systemctl >/dev/null 2>&1; then + echo "Nie znaleziono systemctl, nie można zrestartować PostgreSQL." >&2 + return 1 + fi + + local major="" + if [[ "$PG_VERSION_NUM" =~ ^[0-9]+$ ]] && [ "$PG_VERSION_NUM" -ge 100000 ]; then + major="$((PG_VERSION_NUM / 10000))" + fi + + local candidates=() + if [ -n "$major" ]; then + candidates+=("postgresql-${major}" "postgresql@${major}-main") + fi + candidates+=("postgresql") + + local service + for service in "${candidates[@]}"; do + if systemctl list-unit-files --no-legend "${service}.service" 2>/dev/null | grep -q . || systemctl status "$service" >/dev/null 2>&1; then + if systemctl restart "$service"; then + echo "Restarted PostgreSQL service: $service" + return 0 + fi + fi + done + + echo "Nie znaleziono usługi PostgreSQL do restartu. Próbowano: ${candidates[*]}" >&2 + return 1 +} + +reload_csf_if_available() { + if command -v csf >/dev/null 2>&1; then + csf -r >/dev/null 2>&1 || true + fi +} + +parse_args "$@" +load_plugin_settings +load_credentials + +PSQL_BIN="$(find_psql)" +[ -n "$PSQL_BIN" ] || { + echo "Nie znaleziono binarki psql" >&2 + exit 1 +} + +HBA_FILE="$(psql_exec -c 'SHOW hba_file;')" +[ -n "$HBA_FILE" ] || { + echo "Nie udało się odczytać ścieżki do pg_hba.conf" >&2 + exit 1 +} + +POSTGRESQL_CONF="$(psql_exec -c 'SHOW config_file;')" +[ -n "$POSTGRESQL_CONF" ] || { + echo "Nie udało się odczytać ścieżki do postgresql.conf" >&2 + exit 1 +} + +load_server_version_num + +HBA_DIR="$(dirname "$HBA_FILE")" +HBA_INCLUDE_FILE="$HBA_DIR/pg_hba_da_plugin.conf" + +build_hba_include "$HBA_INCLUDE_FILE" +cleanup_hba_managed_entries "$HBA_FILE" +write_hba_inline_block "$HBA_FILE" "$HBA_INCLUDE_FILE" + +CSF_SYNCED="0" +CSF_SKIP_REASON="" + +if [ "$ALLOW_REMOTE_HOSTS" = "1" ]; then + write_postgresql_conf_block "$POSTGRESQL_CONF" + + if ensure_csf_include_directive "$CSF_ALLOW_FILE" "$CSF_INCLUDE_FILE"; then + write_csf_include_file "$CSF_INCLUDE_FILE" + reload_csf_if_available + CSF_SYNCED="1" + else + CSF_SKIP_REASON="CSF allow file not found: $CSF_ALLOW_FILE" + echo "Ostrzeżenie: nie znaleziono $CSF_ALLOW_FILE, pomijam aktualizację CSF." >&2 + fi +fi + +reload_postgres_conf + +if [ "$RESTART_POSTGRESQL" = "1" ]; then + restart_postgresql_service +fi + +echo "Synchronized pg_hba inline block in: $HBA_FILE" +echo "Generated pg_hba include file (diagnostic copy): $HBA_INCLUDE_FILE" +if [ "$ALLOW_REMOTE_HOSTS" = "1" ]; then + echo "Synchronized postgresql.conf: $POSTGRESQL_CONF" + echo "PostgreSQL restart required for listen_addresses changes" + if [ "$CSF_SYNCED" = "1" ]; then + echo "Synchronized CSF include: $CSF_INCLUDE_FILE" + else + echo "Skipped CSF synchronization: ${CSF_SKIP_REASON:-CSF allow file unavailable}" + fi +else + echo "Remote hosts disabled: skipped postgresql.conf and CSF synchronization" +fi diff --git a/scripts/setup/postgres_backup.sh b/scripts/setup/postgres_backup.sh new file mode 100755 index 0000000..b1a5bf6 --- /dev/null +++ b/scripts/setup/postgres_backup.sh @@ -0,0 +1,226 @@ +#!/bin/bash +# ============================================================================= +# postgres_backup.sh +# Backup wszystkich baz PostgreSQL na serwerze +# +# Przeznaczenie: cron (nie interaktywny) +# Wymagania: pg_dump, psql, dostęp do pliku poświadczeń (format pgpass) +# +# Przywracanie pojedynczej bazy: +# psql -h localhost -U postgres -d postgres -f nazwa_bazy.sql +# gunzip -c nazwa_bazy.sql.gz | psql -h localhost -U postgres -d postgres +# ============================================================================= + +# ============================================================================= +# KONFIGURACJA +# ============================================================================= + +BASE_DIR="/home/admin/postgres_backup" + +# Format daty katalogu backupu (składnia date +FORMAT) +DATE_FORMAT="%d-%m-%Y" + +# 1 = dodaj godzinę i minuty do nazwy katalogu (np. 19-02-2026_14-35) +ADD_TIME=0 + +# Liczba dni przechowywania backupów (0 = bez automatycznego usuwania) +RETENTION=7 + +# 1 = kompresuj do .sql.gz | 0 = zostawiaj jako .sql +ENABLE_COMPRESSION=1 + +# Plik poświadczeń w formacie pgpass: host:port:database:user:password +# Musi mieć uprawnienia 600 lub 400 +CREDENTIALS_FILE="/usr/local/directadmin/conf/postgresql.conf" + +# Bazy wykluczone z backupu (oddzielone spacją) +EXCLUDE_DBS="template0 template1" + +# ============================================================================= +# WEWNĘTRZNA KONFIGURACJA – nie zmieniaj +# ============================================================================= + +LOCK_FILE="/var/run/postgres_backup.lock" +LOG_FILE="/var/log/postgres_backup.log" + +# ============================================================================= +# INICJALIZACJA +# ============================================================================= + +set -uo pipefail + +# Kolory tylko na terminalu +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; NC='' +fi + +ts() { date '+%Y-%m-%d %H:%M:%S'; } +log() { echo "[$(ts)] $*" | tee -a "$LOG_FILE"; } +ok() { echo -e "${GREEN}[OK]${NC} [$(ts)] $*" | tee -a "$LOG_FILE"; } +warn() { echo -e "${YELLOW}[WARN]${NC} [$(ts)] $*" | tee -a "$LOG_FILE"; } +die() { echo -e "${RED}[BŁĄD]${NC} [$(ts)] $*" | tee -a "$LOG_FILE" >&2; cleanup; exit 1; } + +cleanup() { rm -f "$LOCK_FILE"; } +trap cleanup EXIT INT TERM + +# --------------------------------------------------------------------------- +# Blokada przed równoległym uruchomieniem +# --------------------------------------------------------------------------- +if [[ -f "$LOCK_FILE" ]]; then + OLD_PID=$(cat "$LOCK_FILE" 2>/dev/null || echo "") + if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then + die "Backup jest już uruchomiony (PID: ${OLD_PID}). Przerywam." + fi + warn "Stały plik blokady (PID ${OLD_PID} nie istnieje) – usuwam i kontynuuję." + rm -f "$LOCK_FILE" +fi +echo $$ > "$LOCK_FILE" + +# --------------------------------------------------------------------------- +# Weryfikacja pliku poświadczeń +# --------------------------------------------------------------------------- +[[ -f "$CREDENTIALS_FILE" ]] \ + || die "Brak pliku poświadczeń: ${CREDENTIALS_FILE}" + +_perms=$(stat -c '%a' "$CREDENTIALS_FILE") +[[ "$_perms" == "600" || "$_perms" == "400" ]] \ + || die "Plik ${CREDENTIALS_FILE} ma zbyt otwarte uprawnienia (${_perms}). Wymagane: 600 lub 400." + +# Parsowanie: host:port:database:user:password +_line=$(head -1 "$CREDENTIALS_FILE") +PGHOST=$(echo "$_line" | cut -d: -f1) +PGPORT=$(echo "$_line" | cut -d: -f2) +PGUSER=$(echo "$_line" | cut -d: -f4) + +[[ -n "$PGHOST" && -n "$PGPORT" && -n "$PGUSER" ]] \ + || die "Nieprawidłowy format pliku poświadczeń. Oczekiwany: host:port:database:user:password" + +# Hasło przekazywane przez PGPASSFILE – nie pojawia się w środowisku ani linii poleceń +export PGPASSFILE="$CREDENTIALS_FILE" + +# --------------------------------------------------------------------------- +# Katalog backupu +# --------------------------------------------------------------------------- +if [[ "$ADD_TIME" == "1" ]]; then + DATE_STR=$(date +"${DATE_FORMAT}_%H-%M") +else + DATE_STR=$(date +"${DATE_FORMAT}") +fi + +BACKUP_DIR="${BASE_DIR}/${DATE_STR}" +mkdir -p "$BACKUP_DIR" || die "Nie można utworzyć katalogu backupu: ${BACKUP_DIR}" + +# ============================================================================= +# BACKUP +# ============================================================================= + +log "=================================================================" +log "Rozpoczęcie backupu PostgreSQL" +log "Host: ${PGHOST}:${PGPORT} Użytkownik: ${PGUSER}" +log "Katalog: ${BACKUP_DIR}" +log "Kompresja: ${ENABLE_COMPRESSION} Retencja: ${RETENTION} dni" +log "=================================================================" + +# --------------------------------------------------------------------------- +# Globals: użytkownicy, role, hasła, przynależności (pg_dumpall --globals-only) +# Wymagane do pełnego disaster recovery – przywróć jako pierwsze +# --------------------------------------------------------------------------- +ERRORS=0 +BACKED_UP=0 +GLOBALS_SUCCESS=true + +if [[ "$ENABLE_COMPRESSION" == "1" ]]; then + GLOBALS_FILE="${BACKUP_DIR}/_globals.sql.gz" + pg_dumpall \ + -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password --globals-only --clean --if-exists \ + 2>>"$LOG_FILE" \ + | gzip -9 > "$GLOBALS_FILE" \ + || GLOBALS_SUCCESS=false +else + GLOBALS_FILE="${BACKUP_DIR}/_globals.sql" + pg_dumpall \ + -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password --globals-only --clean --if-exists \ + > "$GLOBALS_FILE" 2>>"$LOG_FILE" \ + || GLOBALS_SUCCESS=false +fi + +if [[ "$GLOBALS_SUCCESS" == "true" ]]; then + SIZE=$(du -sh "$GLOBALS_FILE" 2>/dev/null | cut -f1) + ok "_globals → ${GLOBALS_FILE##*/} (${SIZE})" +else + warn "_globals: backup ról i użytkowników nie powiódł się – plik usunięty." + rm -f "$GLOBALS_FILE" + ERRORS=$((ERRORS + 1)) +fi + +# Lista baz danych +DATABASES=$( + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password -t -A \ + -c "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname;" \ + 2>>"$LOG_FILE" +) || die "Nie można pobrać listy baz. Sprawdź połączenie i poświadczenia." + +# Zbuduj wzorzec wykluczeń +EXCLUDE_PATTERN=$(echo "$EXCLUDE_DBS" | tr ' ' '|') + +for DB in $DATABASES; do + # Pomiń wykluczone + if echo "$DB" | grep -qE "^(${EXCLUDE_PATTERN})$"; then + continue + fi + + SUCCESS=true + + if [[ "$ENABLE_COMPRESSION" == "1" ]]; then + OUT_FILE="${BACKUP_DIR}/${DB}.sql.gz" + pg_dump \ + -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password --create --clean --if-exists \ + -F p "$DB" 2>>"$LOG_FILE" \ + | gzip -9 > "$OUT_FILE" \ + || SUCCESS=false + else + OUT_FILE="${BACKUP_DIR}/${DB}.sql" + pg_dump \ + -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password --create --clean --if-exists \ + -F p "$DB" > "$OUT_FILE" 2>>"$LOG_FILE" \ + || SUCCESS=false + fi + + if [[ "$SUCCESS" == "true" ]]; then + SIZE=$(du -sh "$OUT_FILE" 2>/dev/null | cut -f1) + ok "${DB} → ${OUT_FILE##*/} (${SIZE})" + BACKED_UP=$((BACKED_UP + 1)) + else + warn "${DB}: backup nie powiódł się – plik usunięty." + rm -f "$OUT_FILE" + ERRORS=$((ERRORS + 1)) + fi +done + +log "Wynik: ${BACKED_UP} OK, ${ERRORS} błędów." + +# ============================================================================= +# RETENCJA +# ============================================================================= + +if [[ "$RETENTION" -gt 0 ]]; then + log "Usuwanie backupów starszych niż ${RETENTION} dni..." + find "$BASE_DIR" -maxdepth 1 -mindepth 1 -type d -mtime +"$RETENTION" \ + -exec rm -rf {} + 2>/dev/null || true +fi + +# ============================================================================= +# ZAKOŃCZENIE +# ============================================================================= + +log "Backup zakończony." + +[[ "$ERRORS" -eq 0 ]] || exit 1 +exit 0 diff --git a/scripts/setup/postgresql_install.sh b/scripts/setup/postgresql_install.sh new file mode 100755 index 0000000..1770131 --- /dev/null +++ b/scripts/setup/postgresql_install.sh @@ -0,0 +1,786 @@ +#!/bin/bash +# ============================================================================= +# postgresql_install.sh +# Instalacja PostgreSQL na AlmaLinux 8/9, CloudLinux 8/9 +# i systemach kompatybilnych z RHEL pod kontrolą DirectAdmin +# +# Polityka bezpieczeństwa: +# - Dostęp wyłącznie przez localhost (listen_addresses = 'localhost') +# - Uwierzytelnianie hasłem dla WSZYSTKICH połączeń (brak peer, brak trust) +# - Połączenia replikacji odrzucone +# +# Rozszerzenia PHP: +# - Podejście przez custom configure (zapewnia pgsql + pdo_pgsql) +# - Obsługa: apache, nginx, nginx_apache, litespeed, openlitespeed +# - Tryby PHP: php-fpm, fastcgi, lsphp +# ============================================================================= + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Kolory +# --------------------------------------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +sekcja() { echo -e "\n${BOLD}${CYAN}=== $* ===${NC}\n"; } +die() { echo -e "${RED}[BŁĄD]${NC} $*" >&2; exit 1; } + +CB_PHP_WEBSERVER="" +CB_PHP_OPTIONS_CONF="" +DA_PG_RUNTIME_LIBDIR="" +DA_PG_RUNTIME_LIBPQ="" +declare -ag CB_PHP_VERSIONS=() +declare -ag CB_PHP_CODES=() +declare -ag CB_PHP_MODES=() + +cb_pgsql_reset_php_targets() { + CB_PHP_VERSIONS=() + CB_PHP_CODES=() + CB_PHP_MODES=() +} + +cb_pgsql_get_cb_option() { + local options_conf="$1" + local key="$2" + local value="" + + [[ -f "$options_conf" ]] || return 1 + value="$(awk -F= -v key="$key" ' + $1 == key { + sub(/^[[:space:]]+/, "", $2) + sub(/[[:space:]]+$/, "", $2) + print $2 + exit + } + ' "$options_conf")" + [[ -n "$value" ]] || return 1 + printf '%s\n' "$value" +} + +cb_pgsql_php_code_from_version() { + printf '%s\n' "${1//./}" +} + +cb_pgsql_php_version_from_code() { + local code="$1" + if [[ "$code" == *.* ]]; then + printf '%s\n' "$code" + elif [[ "$code" =~ ^[0-9]{2,3}$ ]]; then + printf '%s.%s\n' "${code:0:1}" "${code:1}" + else + printf '%s\n' "$code" + fi +} + +cb_pgsql_add_php_target() { + local version="$1" + local mode="$2" + local code="" + local idx="" + + [[ -n "$version" ]] || return 0 + code="$(cb_pgsql_php_code_from_version "$version")" + for idx in "${!CB_PHP_CODES[@]}"; do + if [[ "${CB_PHP_CODES[$idx]}" == "$code" ]]; then + return 0 + fi + done + + CB_PHP_VERSIONS+=("$version") + CB_PHP_CODES+=("$code") + CB_PHP_MODES+=("${mode:-php-fpm}") +} + +cb_pgsql_detect_webserver() { + local cb_dir="$1" + + CB_PHP_OPTIONS_CONF="${cb_dir}/options.conf" + CB_PHP_WEBSERVER="apache" + + if [[ -f "$CB_PHP_OPTIONS_CONF" ]]; then + CB_PHP_WEBSERVER="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "webserver" || true)" + [[ -n "$CB_PHP_WEBSERVER" ]] || CB_PHP_WEBSERVER="apache" + fi +} + +cb_pgsql_collect_php_targets() { + local cb_dir="$1" + local slot="" + local release="" + local mode="" + local default_mode="" + + cb_pgsql_reset_php_targets + cb_pgsql_detect_webserver "$cb_dir" + + # CloudLinux może używać lsphp także przy webserver=apache. + # Dlatego najpierw honorujemy phpN_mode/php1_mode z options.conf, + # a dopiero przy ich braku stosujemy domyślny tryb wynikający z webserwera. + case "$CB_PHP_WEBSERVER" in + litespeed|openlitespeed) default_mode="lsphp" ;; + *) default_mode="php-fpm" ;; + esac + + for slot in 1 2 3 4 5 6 7 8 9; do + release="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php${slot}_release" || true)" + mode="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php${slot}_mode" || true)" + [[ -n "$mode" ]] || mode="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php1_mode" || true)" + [[ -n "$mode" ]] || mode="$default_mode" + case "${release,,}" in + ""|no|off) continue ;; + esac + case "${mode,,}" in + no|off) continue ;; + esac + cb_pgsql_add_php_target "$release" "$mode" + done +} + +cb_pgsql_build_file_candidates() { + local cb_dir="$1" + local mode="$2" + local code="$3" + local -n out_ref="$4" + + out_ref=() + if [[ "$mode" == "lsphp" ]]; then + case "$CB_PHP_WEBSERVER" in + litespeed) + out_ref+=("${cb_dir}/configure/litespeed/configure.php${code}") + ;; + openlitespeed) + out_ref+=("${cb_dir}/configure/openlitespeed/configure.php${code}") + ;; + esac + out_ref+=("${cb_dir}/configure/lsphp/configure.lsphp${code}") + out_ref+=("${cb_dir}/configure/lsphp/configure.php${code}") + fi + out_ref+=("${cb_dir}/configure/php/configure.php${code}") +} + +cb_pgsql_resolve_config_paths() { + local cb_dir="$1" + local version="$2" + local mode="$3" + local source_ref_name="$4" + local custom_ref_name="$5" + local code="" + local -a candidates=() + local candidate="" + + code="$(cb_pgsql_php_code_from_version "$version")" + printf -v "$source_ref_name" '%s' "" + printf -v "$custom_ref_name" '%s' "" + + cb_pgsql_build_file_candidates "$cb_dir" "$mode" "$code" candidates + + for candidate in "${candidates[@]}"; do + if [[ -f "$candidate" ]]; then + printf -v "$source_ref_name" '%s' "$candidate" + printf -v "$custom_ref_name" '%s' "$(printf '%s\n' "$candidate" | sed "s#^${cb_dir}/configure/#${cb_dir}/custom/#")" + return 0 + fi + done + + for candidate in "${candidates[@]}"; do + candidate="$(printf '%s\n' "$candidate" | sed "s#^${cb_dir}/configure/#${cb_dir}/custom/#")" + if [[ -f "$candidate" ]]; then + printf -v "$source_ref_name" '%s' "$candidate" + printf -v "$custom_ref_name" '%s' "$candidate" + return 0 + fi + done + + return 1 +} + +cb_pgsql_insert_flags() { + local config_file="$1" + local pg_prefix="$2" + local tmp_file="${config_file}.tmp" + + sed -i '/--with-pgsql=/d; /--with-pdo-pgsql=/d' "$config_file" + + if grep -q -- '--with-pdo-mysql' "$config_file"; then + awk -v prefix="$pg_prefix" ' + /--with-pdo-mysql/ { + print + print "\t--with-pgsql=" prefix " \\" + print "\t--with-pdo-pgsql=" prefix " \\" + next + } + { print } + ' "$config_file" > "$tmp_file" + else + awk -v prefix="$pg_prefix" ' + /^[[:space:]]*--with/ { last = NR } + { lines[NR] = $0 } + END { + if (last == 0) { + for (i = 1; i <= NR; i++) print lines[i] + exit + } + for (i = 1; i <= NR; i++) { + print lines[i] + if (i == last) { + print "\t--with-pgsql=" prefix " \\" + print "\t--with-pdo-pgsql=" prefix " \\" + } + } + } + ' "$config_file" > "$tmp_file" + fi + + mv "$tmp_file" "$config_file" +} + +cb_pgsql_prepare_custom_config() { + local cb_dir="$1" + local version="$2" + local mode="$3" + local pg_prefix="$4" + local source_conf="" + local custom_conf="" + local ts="" + + if ! cb_pgsql_resolve_config_paths "$cb_dir" "$version" "$mode" source_conf custom_conf; then + warn "Nie znaleziono configure dla PHP ${version} (tryb ${mode}, webserver ${CB_PHP_WEBSERVER})." + return 1 + fi + + mkdir -p "$(dirname "$custom_conf")" + ts="$(date +%s)" + + if [[ "$source_conf" != "$custom_conf" && ! -f "$custom_conf" ]]; then + cp -p "$source_conf" "$custom_conf" + elif [[ -f "$custom_conf" ]]; then + cp -p "$custom_conf" "${custom_conf}.bak.${ts}" + fi + + cb_pgsql_insert_flags "$custom_conf" "$pg_prefix" + chmod +x "$custom_conf" || true + info "Przygotowano configure dla PHP ${version}: ${custom_conf}" + return 0 +} + +cb_pgsql_configure_runtime_libpq() { + local pg_prefix="$1" + local pg_libdir="${pg_prefix}/lib" + local libpq_so="${pg_libdir}/libpq.so.5" + local ld_conf="/etc/ld.so.conf.d/00-da-postgresql-libpq.conf" + local old_ld_conf="/etc/ld.so.conf.d/da-postgresql-libpq.conf" + local preferred="" + + [[ -d "$pg_libdir" ]] || die "Nie znaleziono katalogu bibliotek PostgreSQL: ${pg_libdir}" + [[ -r "$libpq_so" ]] || die "Nie znaleziono biblioteki libpq: ${libpq_so}" + + printf '%s\n' "$pg_libdir" > "$ld_conf" + chmod 644 "$ld_conf" + [[ "$old_ld_conf" == "$ld_conf" ]] || rm -f "$old_ld_conf" + + if command -v ldconfig >/dev/null 2>&1; then + ldconfig || die "Nie udało się odświeżyć cache bibliotek przez ldconfig." + preferred="$(ldconfig -p 2>/dev/null | awk '/libpq\.so\.5/{print $NF; exit}')" + if [[ -n "$preferred" && "$preferred" != "$libpq_so" ]]; then + warn "Domyślna libpq to ${preferred}; wymuszę właściwą bibliotekę przez LD_PRELOAD podczas kompilacji PHP." + fi + else + warn "Brak ldconfig. Kontynuuję z LD_LIBRARY_PATH." + fi + + DA_PG_RUNTIME_LIBDIR="$pg_libdir" + DA_PG_RUNTIME_LIBPQ="$libpq_so" + info "Skonfigurowano bibliotekę runtime PostgreSQL: ${pg_libdir}" +} + +cb_pgsql_find_php_bin_for_version() { + local version="$1" + local code="" + local candidate="" + + code="$(cb_pgsql_php_code_from_version "$version")" + for candidate in \ + "/usr/local/php${code}/bin/php" \ + "/usr/local/php${code}/bin/lsphp" \ + "/usr/local/lsphp${code}/bin/lsphp" \ + "/usr/local/php${code}/bin/php-cgi"; do + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +cb_pgsql_probe_php_extensions() { + local php_bin="$1" + local pgsql_ref_name="$2" + local pdo_ref_name="$3" + local modules="" + local modules_lc="" + local probe_pgsql_status="NIE" + local probe_pdo_status="NIE" + + modules="$("$php_bin" -m 2>/dev/null || true)" + if [[ -z "$modules" ]]; then + probe_pgsql_status="BŁĄD" + probe_pdo_status="BŁĄD" + printf -v "$pgsql_ref_name" '%s' "$probe_pgsql_status" + printf -v "$pdo_ref_name" '%s' "$probe_pdo_status" + return 1 + fi + + modules_lc="$(printf '%s\n' "$modules" | tr '[:upper:]' '[:lower:]')" + if printf '%s\n' "$modules_lc" | grep -qx 'pgsql'; then + probe_pgsql_status="OK" + fi + if printf '%s\n' "$modules_lc" | grep -qx 'pdo_pgsql'; then + probe_pdo_status="OK" + fi + + printf -v "$pgsql_ref_name" '%s' "$probe_pgsql_status" + printf -v "$pdo_ref_name" '%s' "$probe_pdo_status" + return 0 +} + +cb_pgsql_render_extensions_table() { + local found=0 + local idx="" + local version="" + local php_bin="" + local pgsql_status="" + local pdo_pgsql_status="" + + echo "+------------+-------+-----------+" + printf "| %-10s | %-5s | %-9s |\n" "Wersja PHP" "PGSQL" "PDO_PGSQL" + echo "+------------+-------+-----------+" + + for idx in "${!CB_PHP_VERSIONS[@]}"; do + version="${CB_PHP_VERSIONS[$idx]}" + php_bin="$(cb_pgsql_find_php_bin_for_version "$version" || true)" + if [[ -z "$php_bin" ]]; then + printf "| %-10s | %-5s | %-9s |\n" "PHP ${version}" "BRAK" "BRAK" + found=1 + continue + fi + + cb_pgsql_probe_php_extensions "$php_bin" pgsql_status pdo_pgsql_status >/dev/null 2>&1 || true + printf "| %-10s | %-5s | %-9s |\n" "PHP ${version}" "$pgsql_status" "$pdo_pgsql_status" + found=1 + done + + if [[ $found -eq 1 ]]; then + echo "+------------+-------+-----------+" + else + warn "Nie znaleziono wersji PHP do raportu." + fi +} + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +POSTGRES_BACKUP_SRC="${SCRIPT_DIR}/postgres_backup.sh" +POSTGRES_BACKUP_DST="/opt/postgres_backup" +POSTGRES_BACKUP_CRON_LINE="0 2 * * * /opt/postgres_backup >/dev/null 2>&1" + +# --------------------------------------------------------------------------- +# Wymagany root +# --------------------------------------------------------------------------- +[[ $EUID -eq 0 ]] || die "Skrypt musi być uruchomiony jako root." + +# --------------------------------------------------------------------------- +# Plik logu – terminal: kolorowy output / plik: czysty tekst (bez ANSI) +# --------------------------------------------------------------------------- +LOGFILE="/var/log/postgresql_install_$(date +%Y%m%d_%H%M%S).log" +exec 1> >(tee >(sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' >> "$LOGFILE")) 2>&1 +info "Pełny log zapisany w: $LOGFILE" + +# --------------------------------------------------------------------------- +# Wykrywanie systemu operacyjnego +# --------------------------------------------------------------------------- +sekcja "Wykrywanie systemu operacyjnego" + +[[ -f /etc/os-release ]] || die "Nie można wykryć systemu: brak pliku /etc/os-release." +source /etc/os-release + +OS_ID="${ID:-}" +OS_MAJOR="${VERSION_ID%%.*}" + +case "$OS_ID" in + almalinux) OS_LABEL="AlmaLinux ${VERSION_ID}" ;; + cloudlinux) OS_LABEL="CloudLinux ${VERSION_ID}" ;; + centos|rhel|rocky|ol) + OS_LABEL="${NAME} ${VERSION_ID}" + warn "System '${OS_LABEL}' jest kompatybilny z RHEL, ale nie jest głównym celem skryptu." + ;; + *) + die "Nieobsługiwany system: ${OS_ID}. Skrypt obsługuje AlmaLinux/CloudLinux 8 i 9." + ;; +esac + +[[ "$OS_MAJOR" == "8" || "$OS_MAJOR" == "9" ]] \ + || die "Nieobsługiwana wersja główna ${OS_MAJOR}. Obsługiwane są tylko wersje 8 i 9." +[[ "$(uname -m)" == "x86_64" ]] || die "Obsługiwana jest tylko architektura x86_64." + +info "Wykryto: ${OS_LABEL} (EL${OS_MAJOR})" + +# ============================================================================= +# PYTANIA INTERAKTYWNE +# ============================================================================= + +# --------------------------------------------------------------------------- +# P1: Wersja PostgreSQL +# --------------------------------------------------------------------------- +sekcja "Wybór wersji PostgreSQL" + +AVAILABLE_VERSIONS=(14 15 16 17 18) + +echo "Dostępne wersje PostgreSQL:" +idx=1 +for v in "${AVAILABLE_VERSIONS[@]}"; do + echo " [${idx}] PostgreSQL ${v}" + ((idx++)) +done +echo " [${idx}] Inna (podaj ręcznie numer wersji)" +echo "" + +while true; do + read -rp "Wybierz opcję [1-${idx}, domyślnie 4]: " _ver + _ver="${_ver:-4}" + if [[ ! "$_ver" =~ ^[0-9]+$ ]] || (( _ver < 1 || _ver > idx )); then + warn "Podaj liczbę od 1 do ${idx}." + continue + fi + + if (( _ver < idx )); then + PG_VERSION="${AVAILABLE_VERSIONS[$((_ver-1))]}" + break + fi + + while true; do + read -rp "Podaj ręcznie numer wersji PostgreSQL (np. 16 lub 17): " PG_VERSION + if [[ ! "$PG_VERSION" =~ ^[0-9]+$ ]]; then + warn "Dozwolone są tylko liczby całkowite, np. 16 lub 17." + continue + fi + break + done + break +done + +info "Wybrano: PostgreSQL ${PG_VERSION}" + +PG_SERVICE="postgresql-${PG_VERSION}" +PG_BINDIR="/usr/pgsql-${PG_VERSION}/bin" +PG_DATADIR="/var/lib/pgsql/${PG_VERSION}/data" +PG_HBA="${PG_DATADIR}/pg_hba.conf" +PG_CONF="${PG_DATADIR}/postgresql.conf" +PG_AUTH="scram-sha-256" + +# --------------------------------------------------------------------------- +# P2: Rozszerzenia PHP +# --------------------------------------------------------------------------- +CB_DIR="/usr/local/directadmin/custombuild" +INSTALL_PHP="nie" + +if [[ -x "${CB_DIR}/build" ]]; then + while true; do + read -rp "Zainstalować rozszerzenia pgsql i pdo_pgsql dla PHP? [t/n, domyślnie t]: " _php + _php="${_php:-t}" + case "${_php,,}" in + t|tak) INSTALL_PHP="tak"; break ;; + n|nie) INSTALL_PHP="nie"; break ;; + *) warn "Podaj t lub n." ;; + esac + done +else + warn "Nie znaleziono DirectAdmin CustomBuild w ${CB_DIR}/build – pomijam instalację rozszerzeń PHP." +fi + +# ============================================================================= +# INSTALACJA +# ============================================================================= + +genpasswd() { tr -dc 'A-Za-z0-9_' /dev/null; then + info "Usuwam istniejące repozytorium PGDG." + dnf remove -y pgdg-redhat-repo +fi + +info "Instaluję: ${PGDG_RPM}" +dnf install -y "$PGDG_RPM" \ + || die "Nie udało się zainstalować repozytorium PGDG." + +dnf -qy module disable postgresql 2>/dev/null || true + +# --------------------------------------------------------------------------- +# 2. Instalacja pakietów PostgreSQL +# --------------------------------------------------------------------------- +sekcja "Instalacja pakietów PostgreSQL ${PG_VERSION}" + +dnf install -y \ + "postgresql${PG_VERSION}-server" \ + "postgresql${PG_VERSION}-contrib" \ + "postgresql${PG_VERSION}" \ + || die "Nie udało się zainstalować pakietów PostgreSQL." + +# --------------------------------------------------------------------------- +# 3. Inicjalizacja klastra +# --------------------------------------------------------------------------- +sekcja "Inicjalizacja klastra PostgreSQL" + +if [[ -f "${PG_DATADIR}/PG_VERSION" ]]; then + warn "Klaster jest już zainicjalizowany w ${PG_DATADIR}. Pomijam initdb." +else + "${PG_BINDIR}/postgresql-${PG_VERSION}-setup" initdb \ + || die "Inicjalizacja klastra nie powiodła się." +fi + +# --------------------------------------------------------------------------- +# 4. Zabezpieczenie postgresql.conf +# --------------------------------------------------------------------------- +sekcja "Zabezpieczanie postgresql.conf" + +cp -p "$PG_CONF" "${PG_CONF}.bak.$(date +%s)" + +pg_param() { + local param="$1" value="$2" + sed -i "s|^\s*${param}\s*=.*|#&|" "$PG_CONF" + echo "${param} = ${value}" >> "$PG_CONF" +} + +pg_param listen_addresses "'localhost'" +pg_param password_encryption "scram-sha-256" +pg_param max_connections 100 +pg_param unix_socket_permissions 0700 +pg_param log_connections on +pg_param log_disconnections on +pg_param log_hostname off +pg_param log_line_prefix "'%m [%p] %q%u@%d '" + +info "postgresql.conf zaktualizowany." + +# --------------------------------------------------------------------------- +# 5. Tymczasowy pg_hba.conf (tylko trust dla postgres przez socket) +# --------------------------------------------------------------------------- +cp -p "$PG_HBA" "${PG_HBA}.orig.$(date +%s)" + +cat > "$PG_HBA" < "$CRED_FILE" +chmod 600 "$CRED_FILE" +if id diradmin &>/dev/null; then + chown diradmin:diradmin "$CRED_FILE" +fi + +# --------------------------------------------------------------------------- +# 8. Finalny pg_hba.conf +# --------------------------------------------------------------------------- +cat > "$PG_HBA" </dev/null; then + info "Test połączenia: OK" +else + warn "Test połączenia nie powiódł się. Sprawdź pg_hba.conf i password_encryption." +fi + +# --------------------------------------------------------------------------- +# 10. Rozszerzenia PHP pgsql i pdo_pgsql przez CustomBuild +# --------------------------------------------------------------------------- +if [[ "$INSTALL_PHP" == "tak" ]]; then + sekcja "Rozszerzenia PHP pgsql i pdo_pgsql – DirectAdmin CustomBuild" + + cb_pgsql_collect_php_targets "$CB_DIR" + + info "Web serwer: ${CB_PHP_WEBSERVER}" + if [[ ${#CB_PHP_VERSIONS[@]} -gt 0 ]]; then + info "Wykryte wersje PHP: $(IFS=', '; echo "${CB_PHP_VERSIONS[*]}")" + else + warn "Nie wykryto wersji PHP w DirectAdmin CustomBuild." + fi + + info "Instaluję postgresql${PG_VERSION}-devel..." + dnf install -y "postgresql${PG_VERSION}-devel" \ + "postgresql${PG_VERSION}-libs" \ + || die "Nie udało się zainstalować postgresql${PG_VERSION}-devel/libs." + + PG_PREFIX="/usr/pgsql-${PG_VERSION}" + PG_CONFIG_SRC="${PG_BINDIR}/pg_config" + PG_CONFIG_LINK="/usr/local/bin/pg_config" + + if [[ -x "$PG_CONFIG_SRC" ]]; then + if [[ -e "$PG_CONFIG_LINK" && ! -L "$PG_CONFIG_LINK" ]]; then + warn "${PG_CONFIG_LINK} istnieje i nie jest dowiązaniem – pomijam symlink." + else + ln -sf "$PG_CONFIG_SRC" "$PG_CONFIG_LINK" + fi + else + die "Nie znaleziono pg_config w ${PG_CONFIG_SRC}." + fi + + cb_pgsql_configure_runtime_libpq "$PG_PREFIX" + + PHP_PREPARED=0 + for idx in "${!CB_PHP_VERSIONS[@]}"; do + if cb_pgsql_prepare_custom_config "$CB_DIR" "${CB_PHP_VERSIONS[$idx]}" "${CB_PHP_MODES[$idx]}" "$PG_PREFIX"; then + PHP_PREPARED=1 + fi + done + + if [[ "$PHP_PREPARED" -eq 1 ]]; then + cd "$CB_DIR" + LD_LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ + LD_PRELOAD="${DA_PG_RUNTIME_LIBPQ}${LD_PRELOAD:+:${LD_PRELOAD}}" \ + LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LIBRARY_PATH:+:${LIBRARY_PATH}}" \ + PKG_CONFIG_PATH="${DA_PG_RUNTIME_LIBDIR}/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" \ + LDFLAGS="-L${DA_PG_RUNTIME_LIBDIR} ${LDFLAGS:-}" \ + CPPFLAGS="-I${PG_PREFIX}/include ${CPPFLAGS:-}" \ + ./build php \ + || die "Rekompilacja PHP nie powiodła się. Sprawdź logi w ${CB_DIR}/." + ./build rewrite_confs \ + || warn "rewrite_confs zakończył się błędem – sprawdź konfigurację ręcznie." + cd - > /dev/null + info "Rekompilacja PHP zakończona." + else + warn "Nie przygotowano żadnych plików configure dla PHP – pomijam rekompilację." + fi +fi + +# --------------------------------------------------------------------------- +# 11. Instalacja narzędzia backupu w /opt + cron root (02:00) +# --------------------------------------------------------------------------- +sekcja "Instalacja narzędzia backupu i konfiguracja crona" + +[[ -f "$POSTGRES_BACKUP_SRC" ]] || die "Brak pliku: ${POSTGRES_BACKUP_SRC}" + +cp -f "$POSTGRES_BACKUP_SRC" "$POSTGRES_BACKUP_DST" + +chown root:root "$POSTGRES_BACKUP_DST" +chmod 700 "$POSTGRES_BACKUP_DST" + +CURRENT_CRONTAB="$(crontab -l 2>/dev/null || true)" +if printf '%s\n' "$CURRENT_CRONTAB" | grep -Fqx "$POSTGRES_BACKUP_CRON_LINE"; then + info "Wpis cron już istnieje w crontab roota: /opt/postgres_backup" +else + TMP_CRON="$(mktemp)" + if [[ -n "$CURRENT_CRONTAB" ]]; then + printf '%s\n' "$CURRENT_CRONTAB" > "$TMP_CRON" + fi + printf '%s\n' "$POSTGRES_BACKUP_CRON_LINE" >> "$TMP_CRON" + crontab "$TMP_CRON" + rm -f "$TMP_CRON" + info "Dodano wpis do crontab roota (02:00): /opt/postgres_backup" +fi + +info "Skopiowano: ${POSTGRES_BACKUP_DST}" + +# ============================================================================= +# PODSUMOWANIE +# ============================================================================= + +sekcja "Instalacja zakończona" + +# --------------------------------------------------------------------------- +# Tabela rozszerzeń PHP +# --------------------------------------------------------------------------- +if [[ "$INSTALL_PHP" == "tak" ]]; then + echo -e "${BOLD}Rozszerzenia PHP:${NC}" + echo "" + cb_pgsql_collect_php_targets "$CB_DIR" + cb_pgsql_render_extensions_table + echo "" +fi + +info "Pełny log: ${LOGFILE}" +echo -e "${BOLD}Podsumowanie instalacji:${NC}" +printf "Host:\t%s\n" "localhost" +printf "Login:\t%s\n" "postgres" +printf "Hasło:\t%s\n" "${POSTGRES_PASS}" +printf "Port:\t%s\n" "5432" +printf "Plik danych dostępowych:\t%s\n" "${CRED_FILE}" +echo "" +exit 0 diff --git a/scripts/setup/postgresql_upgrade.sh b/scripts/setup/postgresql_upgrade.sh new file mode 100755 index 0000000..bead77f --- /dev/null +++ b/scripts/setup/postgresql_upgrade.sh @@ -0,0 +1,1371 @@ +#!/bin/bash +# ============================================================================= +# postgresql_upgrade.sh +# Aktualizacja PostgreSQL (major upgrade) na AlmaLinux/CloudLinux/RHEL-like +# z zachowaniem danych, ról, użytkowników i haseł: +# - tryb pg_upgrade +# - tryb backup + odtworzenie do nowego klastra +# ============================================================================= + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +sekcja() { echo -e "\n${BOLD}${CYAN}=== $* ===${NC}\n"; } +die() { echo -e "${RED}[BŁĄD]${NC} $*" >&2; exit 1; } + +CB_PHP_WEBSERVER="" +CB_PHP_OPTIONS_CONF="" +declare -ag CB_PHP_VERSIONS=() +declare -ag CB_PHP_CODES=() +declare -ag CB_PHP_MODES=() +declare -ag PHP_FILES_TO_UPDATE=() +declare -ag PHP_VERSIONS_TO_REBUILD=() +DA_PG_RUNTIME_LIBDIR="" +DA_PG_RUNTIME_LIBPQ="" + +cb_pgsql_reset_php_targets() { + CB_PHP_VERSIONS=() + CB_PHP_CODES=() + CB_PHP_MODES=() +} + +cb_pgsql_get_cb_option() { + local options_conf="$1" + local key="$2" + local value="" + + [[ -f "$options_conf" ]] || return 1 + value="$(awk -F= -v key="$key" ' + $1 == key { + sub(/^[[:space:]]+/, "", $2) + sub(/[[:space:]]+$/, "", $2) + print $2 + exit + } + ' "$options_conf")" + [[ -n "$value" ]] || return 1 + printf '%s\n' "$value" +} + +cb_pgsql_php_code_from_version() { + printf '%s\n' "${1//./}" +} + +cb_pgsql_php_version_from_code() { + local code="$1" + if [[ "$code" == *.* ]]; then + printf '%s\n' "$code" + elif [[ "$code" =~ ^[0-9]{2,3}$ ]]; then + printf '%s.%s\n' "${code:0:1}" "${code:1}" + else + printf '%s\n' "$code" + fi +} + +cb_pgsql_add_php_target() { + local version="$1" + local mode="$2" + local code="" + local idx="" + + [[ -n "$version" ]] || return 0 + code="$(cb_pgsql_php_code_from_version "$version")" + for idx in "${!CB_PHP_CODES[@]}"; do + if [[ "${CB_PHP_CODES[$idx]}" == "$code" ]]; then + return 0 + fi + done + + CB_PHP_VERSIONS+=("$version") + CB_PHP_CODES+=("$code") + CB_PHP_MODES+=("${mode:-php-fpm}") +} + +cb_pgsql_detect_webserver() { + local cb_dir="$1" + + CB_PHP_OPTIONS_CONF="${cb_dir}/options.conf" + CB_PHP_WEBSERVER="apache" + + if [[ -f "$CB_PHP_OPTIONS_CONF" ]]; then + CB_PHP_WEBSERVER="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "webserver" || true)" + [[ -n "$CB_PHP_WEBSERVER" ]] || CB_PHP_WEBSERVER="apache" + fi +} + +cb_pgsql_collect_php_targets() { + local cb_dir="$1" + local slot="" + local release="" + local mode="" + local default_mode="" + + cb_pgsql_reset_php_targets + cb_pgsql_detect_webserver "$cb_dir" + + # CloudLinux może używać lsphp także przy webserver=apache. + # Dlatego najpierw honorujemy phpN_mode/php1_mode z options.conf, + # a dopiero przy ich braku stosujemy domyślny tryb wynikający z webserwera. + case "$CB_PHP_WEBSERVER" in + litespeed|openlitespeed) default_mode="lsphp" ;; + *) default_mode="php-fpm" ;; + esac + + for slot in 1 2 3 4 5 6 7 8 9; do + release="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php${slot}_release" || true)" + mode="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php${slot}_mode" || true)" + [[ -n "$mode" ]] || mode="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php1_mode" || true)" + [[ -n "$mode" ]] || mode="$default_mode" + case "${release,,}" in + ""|no|off) continue ;; + esac + case "${mode,,}" in + no|off) continue ;; + esac + cb_pgsql_add_php_target "$release" "$mode" + done +} + +cb_pgsql_build_file_candidates() { + local cb_dir="$1" + local mode="$2" + local code="$3" + local -n out_ref="$4" + + out_ref=() + if [[ "$mode" == "lsphp" ]]; then + case "$CB_PHP_WEBSERVER" in + litespeed) + out_ref+=("${cb_dir}/configure/litespeed/configure.php${code}") + ;; + openlitespeed) + out_ref+=("${cb_dir}/configure/openlitespeed/configure.php${code}") + ;; + esac + out_ref+=("${cb_dir}/configure/lsphp/configure.lsphp${code}") + out_ref+=("${cb_dir}/configure/lsphp/configure.php${code}") + fi + out_ref+=("${cb_dir}/configure/php/configure.php${code}") +} + +cb_pgsql_resolve_config_paths() { + local cb_dir="$1" + local version="$2" + local mode="$3" + local source_ref_name="$4" + local custom_ref_name="$5" + local code="" + local -a candidates=() + local candidate="" + + code="$(cb_pgsql_php_code_from_version "$version")" + printf -v "$source_ref_name" '%s' "" + printf -v "$custom_ref_name" '%s' "" + + cb_pgsql_build_file_candidates "$cb_dir" "$mode" "$code" candidates + + for candidate in "${candidates[@]}"; do + if [[ -f "$candidate" ]]; then + printf -v "$source_ref_name" '%s' "$candidate" + printf -v "$custom_ref_name" '%s' "$(printf '%s\n' "$candidate" | sed "s#^${cb_dir}/configure/#${cb_dir}/custom/#")" + return 0 + fi + done + + for candidate in "${candidates[@]}"; do + candidate="$(printf '%s\n' "$candidate" | sed "s#^${cb_dir}/configure/#${cb_dir}/custom/#")" + if [[ -f "$candidate" ]]; then + printf -v "$source_ref_name" '%s' "$candidate" + printf -v "$custom_ref_name" '%s' "$candidate" + return 0 + fi + done + + return 1 +} + +cb_pgsql_configure_runtime_libpq() { + local pg_prefix="$1" + local pg_libdir="${pg_prefix}/lib" + local libpq_so="${pg_libdir}/libpq.so.5" + local ld_conf="/etc/ld.so.conf.d/00-da-postgresql-libpq.conf" + local old_ld_conf="/etc/ld.so.conf.d/da-postgresql-libpq.conf" + local preferred="" + + [[ -d "$pg_libdir" ]] || die "Nie znaleziono katalogu bibliotek PostgreSQL: ${pg_libdir}" + [[ -r "$libpq_so" ]] || die "Nie znaleziono biblioteki libpq: ${libpq_so}" + + printf '%s\n' "$pg_libdir" > "$ld_conf" + chmod 644 "$ld_conf" + [[ "$old_ld_conf" == "$ld_conf" ]] || rm -f "$old_ld_conf" + + if command -v ldconfig >/dev/null 2>&1; then + ldconfig || die "Nie udało się odświeżyć cache bibliotek przez ldconfig." + preferred="$(ldconfig -p 2>/dev/null | awk '/libpq\.so\.5/{print $NF; exit}')" + if [[ -n "$preferred" && "$preferred" != "$libpq_so" ]]; then + warn "Domyślna libpq to ${preferred}; wymuszę właściwą bibliotekę przez LD_PRELOAD podczas kompilacji PHP." + fi + else + warn "Brak ldconfig. Kontynuuję z LD_LIBRARY_PATH." + fi + + DA_PG_RUNTIME_LIBDIR="$pg_libdir" + DA_PG_RUNTIME_LIBPQ="$libpq_so" + info "Skonfigurowano bibliotekę runtime PostgreSQL: ${pg_libdir}" +} + +[[ $EUID -eq 0 ]] || die "Skrypt musi być uruchomiony jako root." + +LOGFILE="/var/log/postgresql_upgrade_$(date +%Y%m%d_%H%M%S).log" +exec 1> >(tee >(sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' >> "$LOGFILE")) 2>&1 +info "Pełny log zapisany w: $LOGFILE" + +# Konfiguracja backupów przed aktualizacją: +# POSTGRESQL_DUMP_BASEDIR - katalog bazowy dla dumpów +# DATE_FORMAT - format daty (domyślnie dzień-miesiąc-rok) +POSTGRESQL_DUMP_BASEDIR="${POSTGRESQL_DUMP_BASEDIR:-/opt/postgres_upgrade_backups}" +DATE_FORMAT="${DATE_FORMAT:-%d-%m-%Y}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CB_DIR="/usr/local/directadmin/custombuild" +SUPPORTED_TARGETS=(14 15 16 17 18) + +INSTALLED_VERSIONS=() +CURRENT_VERSION="" +TARGET_VERSION="" +CHOSEN_VERSION="" + +DO_BACKUP="tak" +PHP_UPDATE_NEEDED="nie" +BACKUP_DIR="" +BACKUP_RESULT="nie wykonano" +BACKUP_GLOBALS_FILE="" +BACKUP_CLUSTER_FILE="" +UPGRADE_MODE="pg_upgrade" +REQUIRE_LOGICAL_BACKUP="0" +DELETE_OLD_CLUSTER_CMD="" +REMOVE_OLD_SCRIPT_PATH="/opt/remove_old_postgres.sh" +REMOVE_OLD_SCRIPT_RESULT="nie wygenerowano" +INITDB_SUPERUSER="postgres" +RESTORE_MAINT_DB="" + +OLD_SERVICE="" +NEW_SERVICE="" +OLD_BINDIR="" +NEW_BINDIR="" +OLD_DATADIR="" +NEW_DATADIR="" +OLD_CONF="" +NEW_CONF="" +OLD_HBA="" +NEW_HBA="" +OLD_PREFIX="" +NEW_PREFIX="" +OLD_CHECKSUM_VERSION="" +OLD_CHECKSUMS="" +NEW_INITDB_CHECKSUM_FLAG="" +PG_UPGRADE_WORKDIR="" + +OS_MAJOR="" +PGHOST_VAL="" +PGPORT_VAL="" +PGUSER_VAL="" +PGPASSWORD_VAL="" +CRED_FILE="/usr/local/directadmin/conf/postgresql.conf" + +ask_yes_no() { + local prompt="$1" + local default="${2:-t}" # t/n + local answer="" + while true; do + if [[ "$default" == "t" ]]; then + read -rp "${prompt} [T/n]: " answer + answer="${answer:-t}" + else + read -rp "${prompt} [t/N]: " answer + answer="${answer:-n}" + fi + case "${answer,,}" in + t|tak|y|yes) return 0 ;; + n|nie|no) return 1 ;; + *) warn "Podaj t lub n." ;; + esac + done +} + +load_pg_credentials() { + sekcja "Wczytywanie poświadczeń PostgreSQL" + [[ -r "$CRED_FILE" ]] || die "Brak odczytu pliku poświadczeń: ${CRED_FILE}" + + local line rest + line="$(grep -Ev '^[[:space:]]*($|#)' "$CRED_FILE" | head -n1 || true)" + [[ -n "$line" ]] || die "Plik poświadczeń jest pusty: ${CRED_FILE}" + + PGHOST_VAL="" + PGPORT_VAL="" + PGUSER_VAL="" + PGPASSWORD_VAL="" + + if [[ "$line" == *=* ]]; then + while IFS='=' read -r key value; do + key="$(echo "$key" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]')" + value="$(echo "$value" | sed -E 's/[[:space:]]+#.*$//' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + case "$key" in + PG_HOST) PGHOST_VAL="$value" ;; + PG_PORT) PGPORT_VAL="$value" ;; + PG_USER) PGUSER_VAL="$value" ;; + PG_PASSWORD) PGPASSWORD_VAL="$value" ;; + esac + done < <(grep -Ev '^[[:space:]]*($|#)' "$CRED_FILE") + else + PGHOST_VAL="${line%%:*}" + rest="${line#*:}" + PGPORT_VAL="${rest%%:*}" + rest="${rest#*:}" + rest="${rest#*:}" + PGUSER_VAL="${rest%%:*}" + PGPASSWORD_VAL="${rest#*:}" + fi + + [[ -n "$PGHOST_VAL" ]] || PGHOST_VAL="localhost" + [[ -n "$PGPORT_VAL" ]] || PGPORT_VAL="5432" + [[ -n "$PGUSER_VAL" ]] || PGUSER_VAL="postgres" + [[ -n "$PGPASSWORD_VAL" ]] || die "Brak hasła PostgreSQL w pliku: ${CRED_FILE}" + + info "Poświadczenia PostgreSQL wczytane z: ${CRED_FILE}" +} + +contains_target_version() { + local needle="$1" + local v + for v in "${SUPPORTED_TARGETS[@]}"; do + [[ "$v" == "$needle" ]] && return 0 + done + return 1 +} + +detect_os() { + sekcja "Wykrywanie systemu operacyjnego" + [[ -f /etc/os-release ]] || die "Nie można wykryć systemu: brak /etc/os-release." + # shellcheck disable=SC1091 + source /etc/os-release + + OS_MAJOR="${VERSION_ID%%.*}" + case "${ID:-}" in + almalinux|cloudlinux|centos|rhel|rocky|ol) ;; + *) + die "Nieobsługiwany system: ${ID:-unknown}. Obsługiwane: AlmaLinux/CloudLinux/RHEL-like." + ;; + esac + + [[ "$OS_MAJOR" == "8" || "$OS_MAJOR" == "9" ]] \ + || die "Nieobsługiwana wersja główna systemu: ${OS_MAJOR}. Obsługiwane EL8/EL9." + + info "Wykryto system: ${PRETTY_NAME:-$ID} (EL${OS_MAJOR})" +} + +detect_installed_versions() { + sekcja "Wykrywanie zainstalowanych wersji PostgreSQL" + local d v + for d in /usr/pgsql-*; do + [[ -d "$d" ]] || continue + v="${d#/usr/pgsql-}" + [[ "$v" =~ ^[0-9]+$ ]] || continue + [[ -x "$d/bin/pg_upgrade" || -x "$d/bin/postgres" ]] || continue + INSTALLED_VERSIONS+=("$v") + done + + if [[ ${#INSTALLED_VERSIONS[@]} -eq 0 ]]; then + die "Nie wykryto zainstalowanych wersji PostgreSQL w /usr/pgsql-*." + fi + + mapfile -t INSTALLED_VERSIONS < <(printf '%s\n' "${INSTALLED_VERSIONS[@]}" | sort -n | uniq) + info "Wykryte wersje: $(IFS=', '; echo "${INSTALLED_VERSIONS[*]}")" +} + +choose_from_versions() { + local title="$1" + local -n _arr_ref="$2" + local default="$3" + local pick="" + + echo "$title" + local i=1 + local v + for v in "${_arr_ref[@]}"; do + echo " [$i] PostgreSQL $v" + ((i++)) + done + + while true; do + read -rp "Wybierz [1-${#_arr_ref[@]}, domyślnie ${default}]: " pick + pick="${pick:-$default}" + if [[ "$pick" =~ ^[0-9]+$ ]] && (( pick >= 1 && pick <= ${#_arr_ref[@]} )); then + CHOSEN_VERSION="${_arr_ref[$((pick-1))]}" + return 0 + fi + warn "Nieprawidłowy wybór." + done +} + +detect_current_version() { + sekcja "Wybór aktualnej wersji PostgreSQL" + local active_versions=() + local v + for v in "${INSTALLED_VERSIONS[@]}"; do + if systemctl is-active --quiet "postgresql-${v}"; then + active_versions+=("$v") + fi + done + + if [[ ${#active_versions[@]} -eq 1 ]]; then + CURRENT_VERSION="${active_versions[0]}" + info "Wykryto aktywną wersję: PostgreSQL ${CURRENT_VERSION}" + return 0 + fi + + if [[ ${#active_versions[@]} -gt 1 ]]; then + warn "Wykryto więcej niż jedną aktywną wersję PostgreSQL." + choose_from_versions "Wskaż wersję źródłową do aktualizacji:" active_versions 1 + CURRENT_VERSION="$CHOSEN_VERSION" + info "Wybrano wersję źródłową: PostgreSQL ${CURRENT_VERSION}" + return 0 + fi + + if [[ ${#INSTALLED_VERSIONS[@]} -eq 1 ]]; then + CURRENT_VERSION="${INSTALLED_VERSIONS[0]}" + info "Wykryto jedną wersję: PostgreSQL ${CURRENT_VERSION}" + return 0 + fi + + choose_from_versions "Brak aktywnej usługi. Wskaż wersję źródłową:" INSTALLED_VERSIONS 1 + CURRENT_VERSION="$CHOSEN_VERSION" + info "Wybrano wersję źródłową: PostgreSQL ${CURRENT_VERSION}" +} + +choose_target_version() { + sekcja "Wybór wersji docelowej PostgreSQL" + local targets=() + local v + for v in "${SUPPORTED_TARGETS[@]}"; do + if (( v > CURRENT_VERSION )); then + targets+=("$v") + fi + done + + [[ ${#targets[@]} -gt 0 ]] || die "Brak dostępnej nowszej wersji docelowej." + + echo "Dostępne wersje docelowe:" + local idx=1 + for v in "${targets[@]}"; do + echo " [$idx] PostgreSQL $v" + ((idx++)) + done + echo " [$idx] Inna (podaj ręcznie numer wersji)" + + while true; do + local pick + read -rp "Wybierz opcję [1-${idx}, domyślnie 1]: " pick + pick="${pick:-1}" + if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > idx )); then + warn "Podaj liczbę od 1 do ${idx}." + continue + fi + + if (( pick < idx )); then + TARGET_VERSION="${targets[$((pick-1))]}" + break + fi + + while true; do + read -rp "Podaj ręcznie numer wersji PostgreSQL (np. 16 lub 17): " TARGET_VERSION + if [[ ! "$TARGET_VERSION" =~ ^[0-9]+$ ]]; then + warn "Dozwolone są tylko liczby całkowite, np. 16 lub 17." + continue + fi + if (( TARGET_VERSION <= CURRENT_VERSION )); then + warn "Docelowa wersja musi być wyższa niż ${CURRENT_VERSION}." + continue + fi + break + done + break + done + + info "Wybrano docelową wersję: PostgreSQL ${TARGET_VERSION}" +} + +choose_upgrade_mode() { + sekcja "Wybór trybu aktualizacji" + echo " [1] pg_upgrade (szybszy, zachowuje strukturę klastra)" + echo " [2] Backup + odtworzenie (nowy klaster + restore dumpa)" + + local pick="" + while true; do + read -rp "Wybierz tryb [1-2, domyślnie 1]: " pick + pick="${pick:-1}" + case "$pick" in + 1) + UPGRADE_MODE="pg_upgrade" + REQUIRE_LOGICAL_BACKUP="0" + INITDB_SUPERUSER="postgres" + RESTORE_MAINT_DB="" + info "Wybrano tryb: pg_upgrade" + return 0 + ;; + 2) + UPGRADE_MODE="backup_restore" + REQUIRE_LOGICAL_BACKUP="1" + INITDB_SUPERUSER="postgres" + RESTORE_MAINT_DB="da_restore_maint_$(date +%Y%m%d%H%M%S)" + info "Wybrano tryb: backup + odtworzenie" + info "Superuser initdb: ${INITDB_SUPERUSER}" + info "Tymczasowa baza serwisowa restore: ${RESTORE_MAINT_DB}" + return 0 + ;; + *) + warn "Podaj 1 lub 2." + ;; + esac + done +} + +ensure_sudo_available() { + sekcja "Weryfikacja narzędzia sudo" + if command -v sudo >/dev/null 2>&1; then + info "sudo jest już zainstalowane." + return 0 + fi + + warn "Brak sudo. Instaluję pakiet sudo..." + dnf install -y sudo || die "Nie udało się zainstalować sudo." + command -v sudo >/dev/null 2>&1 || die "sudo nadal nie jest dostępne po instalacji." + info "Zainstalowano sudo." +} + +prepare_paths() { + OLD_SERVICE="postgresql-${CURRENT_VERSION}" + NEW_SERVICE="postgresql-${TARGET_VERSION}" + + OLD_BINDIR="/usr/pgsql-${CURRENT_VERSION}/bin" + NEW_BINDIR="/usr/pgsql-${TARGET_VERSION}/bin" + OLD_DATADIR="/var/lib/pgsql/${CURRENT_VERSION}/data" + NEW_DATADIR="/var/lib/pgsql/${TARGET_VERSION}/data" + OLD_CONF="${OLD_DATADIR}/postgresql.conf" + NEW_CONF="${NEW_DATADIR}/postgresql.conf" + OLD_HBA="${OLD_DATADIR}/pg_hba.conf" + NEW_HBA="${NEW_DATADIR}/pg_hba.conf" + OLD_PREFIX="/usr/pgsql-${CURRENT_VERSION}" + NEW_PREFIX="/usr/pgsql-${TARGET_VERSION}" + + [[ -x "${OLD_BINDIR}/pg_upgrade" ]] || die "Brak narzędzi PostgreSQL dla wersji źródłowej: ${OLD_BINDIR}" + [[ -d "$OLD_DATADIR" ]] || die "Brak katalogu danych źródłowych: ${OLD_DATADIR}" + [[ -f "$OLD_CONF" ]] || die "Brak pliku: ${OLD_CONF}" + [[ -f "$OLD_HBA" ]] || die "Brak pliku: ${OLD_HBA}" +} + +detect_old_cluster_checksum_mode() { + sekcja "Wykrywanie ustawień data checksums starego klastra" + + local controldata_bin="${OLD_BINDIR}/pg_controldata" + [[ -x "$controldata_bin" ]] || die "Brak narzędzia pg_controldata: ${controldata_bin}" + + local ctl_out + ctl_out="$(sudo -u postgres "$controldata_bin" "$OLD_DATADIR" 2>/dev/null || true)" + [[ -n "$ctl_out" ]] || die "Nie udało się odczytać metadanych starego klastra (${OLD_DATADIR})." + + local version_raw + version_raw="$(printf '%s\n' "$ctl_out" | awk -F: ' + BEGIN { IGNORECASE=1 } + /checksum/ && /version/ { + v=$2 + gsub(/^[ \t]+|[ \t]+$/, "", v) + if (v ~ /^[0-9]+$/) { + print v + exit + } + } + ')" + + if [[ -n "$version_raw" ]]; then + OLD_CHECKSUM_VERSION="$version_raw" + if [[ "$OLD_CHECKSUM_VERSION" == "0" ]]; then + OLD_CHECKSUMS="off" + NEW_INITDB_CHECKSUM_FLAG="--no-data-checksums" + else + OLD_CHECKSUMS="on" + NEW_INITDB_CHECKSUM_FLAG="--data-checksums" + fi + else + local checksums_raw + checksums_raw="$(printf '%s\n' "$ctl_out" | awk -F: ' + BEGIN { IGNORECASE=1 } + /data[[:space:]]+checksums/ { + v=$2 + gsub(/^[ \t]+|[ \t]+$/, "", v) + print tolower(v) + exit + } + ')" + case "$checksums_raw" in + on) + OLD_CHECKSUM_VERSION="1" + OLD_CHECKSUMS="on" + NEW_INITDB_CHECKSUM_FLAG="--data-checksums" + ;; + off) + OLD_CHECKSUM_VERSION="0" + OLD_CHECKSUMS="off" + NEW_INITDB_CHECKSUM_FLAG="--no-data-checksums" + ;; + *) + die "Nie udało się ustalić trybu data checksums starego klastra." + ;; + esac + fi + + info "Stary klaster checksums: ${OLD_CHECKSUMS} (version=${OLD_CHECKSUM_VERSION})" + info "Nowy klaster zostanie zainicjalizowany flagą: ${NEW_INITDB_CHECKSUM_FLAG}" +} + +detect_php_update_need() { + sekcja "Analiza konfiguracji PHP (configure.phpXX)" + PHP_FILES_TO_UPDATE=() + PHP_VERSIONS_TO_REBUILD=() + PHP_UPDATE_NEEDED="nie" + + cb_pgsql_collect_php_targets "$CB_DIR" + + local idx="" + local source_conf="" + local custom_conf="" + local scan_file="" + for idx in "${!CB_PHP_VERSIONS[@]}"; do + if ! cb_pgsql_resolve_config_paths "$CB_DIR" "${CB_PHP_VERSIONS[$idx]}" "${CB_PHP_MODES[$idx]}" source_conf custom_conf; then + continue + fi + + scan_file="$custom_conf" + if [[ "$scan_file" != "$source_conf" && ! -f "$scan_file" ]]; then + scan_file="$source_conf" + fi + + [[ -f "$scan_file" ]] || continue + if grep -qE -- "--with-pgsql=${OLD_PREFIX}|--with-pdo-pgsql=${OLD_PREFIX}" "$scan_file"; then + PHP_FILES_TO_UPDATE+=("$scan_file") + PHP_VERSIONS_TO_REBUILD+=("${CB_PHP_VERSIONS[$idx]}") + fi + done + + if [[ ${#PHP_FILES_TO_UPDATE[@]} -gt 0 ]]; then + PHP_UPDATE_NEEDED="tak" + info "Wykryto pliki do aktualizacji ścieżek PostgreSQL: ${#PHP_FILES_TO_UPDATE[@]}" + else + info "Nie wykryto plików configure PHP wymagających zmian." + fi +} + +print_summary_and_confirm() { + sekcja "Podsumowanie operacji" + printf "Aktualna wersja PostgreSQL:\t%s\n" "$CURRENT_VERSION" + printf "Docelowa wersja PostgreSQL:\t%s\n" "$TARGET_VERSION" + printf "Tryb aktualizacji:\t%s\n" "$UPGRADE_MODE" + if [[ "$UPGRADE_MODE" == "backup_restore" ]]; then + printf "Superuser initdb:\t%s\n" "$INITDB_SUPERUSER" + printf "Baza serwisowa restore (tymczasowa):\t%s\n" "$RESTORE_MAINT_DB" + fi + printf "Aktualny katalog danych:\t%s\n" "$OLD_DATADIR" + printf "Docelowy katalog danych:\t%s\n" "$NEW_DATADIR" + printf "Wykonanie backupu istniejących baz danych:\t%s\n" "$DO_BACKUP" + printf "Aktualizacja PHP:\t%s\n" "$PHP_UPDATE_NEEDED" + echo "" + + if [[ "$PHP_UPDATE_NEEDED" == "tak" ]]; then + echo "Pliki configure do aktualizacji:" + local f + for f in "${PHP_FILES_TO_UPDATE[@]}"; do + echo " - $f" + done + echo "" + fi + + ask_yes_no "Czy kontynuować aktualizację?" "n" || die "Przerwano przez użytkownika." +} + +install_target_packages() { + sekcja "Instalacja pakietów PostgreSQL ${TARGET_VERSION}" + local pgdg_rpm="https://download.postgresql.org/pub/repos/yum/reporpms/EL-${OS_MAJOR}-x86_64/pgdg-redhat-repo-latest.noarch.rpm" + + if ! rpm -q pgdg-redhat-repo &>/dev/null; then + info "Instaluję repozytorium PGDG." + dnf install -y "$pgdg_rpm" || die "Nie udało się zainstalować repozytorium PGDG." + fi + + dnf -qy module disable postgresql 2>/dev/null || true + dnf install -y \ + "postgresql${TARGET_VERSION}-server" \ + "postgresql${TARGET_VERSION}-contrib" \ + "postgresql${TARGET_VERSION}" \ + "postgresql${TARGET_VERSION}-libs" \ + "postgresql${TARGET_VERSION}-devel" \ + || die "Instalacja pakietów PostgreSQL ${TARGET_VERSION} nie powiodła się." +} + +ensure_old_service_running_for_backup() { + if systemctl is-active --quiet "$OLD_SERVICE"; then + return 0 + fi + warn "Usługa ${OLD_SERVICE} jest zatrzymana. Uruchamiam tymczasowo do wykonania backupu." + if systemctl start "$OLD_SERVICE"; then + return 0 + fi + if systemctl is-active --quiet "$OLD_SERVICE"; then + return 0 + fi + return 1 +} + +run_offline_cluster_backup() { + local backup_stamp="$1" + local data_archive="${BACKUP_DIR}/physical_cluster_${backup_stamp}.tar.gz" + local config_archive="${BACKUP_DIR}/config_${backup_stamp}.tar.gz" + + [[ -d "$OLD_DATADIR" ]] || die "Brak katalogu danych źródłowych do backupu offline: ${OLD_DATADIR}" + [[ -f "$OLD_CONF" ]] || die "Brak pliku konfiguracyjnego do backupu offline: ${OLD_CONF}" + [[ -f "$OLD_HBA" ]] || die "Brak pliku pg_hba do backupu offline: ${OLD_HBA}" + + if systemctl is-active --quiet "$OLD_SERVICE"; then + die "Backup offline wymaga zatrzymanej usługi ${OLD_SERVICE}." + fi + + warn "Nie udało się uruchomić ${OLD_SERVICE}. Tworzę fallback: fizyczny backup offline." + + info "Tworzę archiwum katalogu danych starego klastra..." + tar -C "$(dirname "$OLD_DATADIR")" -czf "$data_archive" "$(basename "$OLD_DATADIR")" \ + || die "Nie udało się utworzyć archiwum danych starego klastra." + + info "Tworzę archiwum plików konfiguracyjnych starego klastra..." + tar -czf "$config_archive" "$OLD_CONF" "$OLD_HBA" \ + || die "Nie udało się utworzyć archiwum konfiguracji starego klastra." + + chmod 600 "$data_archive" "$config_archive" || true + BACKUP_RESULT="fizyczny offline (tar)" + warn "Utworzono backup fizyczny offline zamiast backupu logicznego pg_dumpall." +} + +run_full_backup() { + [[ "$DO_BACKUP" == "tak" ]] || return 0 + sekcja "Wykonywanie pełnego backupu klastra PostgreSQL" + + BACKUP_GLOBALS_FILE="" + BACKUP_CLUSTER_FILE="" + + local backup_date + local backup_stamp + backup_date="$(date +"${DATE_FORMAT}")" + backup_stamp="$(date +%H%M%S)" + + BACKUP_DIR="${POSTGRESQL_DUMP_BASEDIR}/postgresql-${CURRENT_VERSION}/${backup_date}" + mkdir -p "$BACKUP_DIR" + chmod 700 "$BACKUP_DIR" + + local old_pg_dumpall="${OLD_BINDIR}/pg_dumpall" + [[ -x "$old_pg_dumpall" ]] || die "Brak narzędzia: ${old_pg_dumpall}" + + if ! ensure_old_service_running_for_backup; then + if [[ "$REQUIRE_LOGICAL_BACKUP" == "1" ]]; then + die "Wybrany tryb wymaga backupu logicznego, ale nie można uruchomić ${OLD_SERVICE}." + fi + run_offline_cluster_backup "$backup_stamp" + info "Backup zapisany w: ${BACKUP_DIR}" + return 0 + fi + + local globals_sql="${BACKUP_DIR}/globals_${backup_stamp}.sql" + local full_sql="${BACKUP_DIR}/full_cluster_${backup_stamp}.sql" + local full_dump_opts=(--no-password) + if [[ "$UPGRADE_MODE" == "backup_restore" ]]; then + # Zalecenie z dokumentacji pg_dumpall: + # dla restore na nowym klastrze używamy --clean --if-exists. + full_dump_opts+=(--clean --if-exists) + fi + + info "Tworzę backup globalny (role/użytkownicy/uprawnienia)..." + sudo -u postgres env \ + PGHOST="$PGHOST_VAL" \ + PGPORT="$PGPORT_VAL" \ + PGUSER="$PGUSER_VAL" \ + PGPASSWORD="$PGPASSWORD_VAL" \ + "$old_pg_dumpall" --no-password --globals-only > "$globals_sql" + + if [[ "$UPGRADE_MODE" == "backup_restore" ]]; then + info "Tworzę pełny backup wszystkich baz (tryb restore: --clean --if-exists)..." + else + info "Tworzę pełny backup wszystkich baz..." + fi + sudo -u postgres env \ + PGHOST="$PGHOST_VAL" \ + PGPORT="$PGPORT_VAL" \ + PGUSER="$PGUSER_VAL" \ + PGPASSWORD="$PGPASSWORD_VAL" \ + "$old_pg_dumpall" "${full_dump_opts[@]}" > "$full_sql" + + gzip -f "$globals_sql" "$full_sql" + BACKUP_GLOBALS_FILE="${globals_sql}.gz" + BACKUP_CLUSTER_FILE="${full_sql}.gz" + BACKUP_RESULT="logiczny (pg_dumpall)" + info "Backup zapisany w: ${BACKUP_DIR}" +} + +init_new_cluster() { + sekcja "Inicjalizacja nowego klastra PostgreSQL ${TARGET_VERSION}" + + [[ -n "$NEW_INITDB_CHECKSUM_FLAG" ]] || die "Brak ustawionego trybu checksums dla nowego klastra." + + if [[ -f "${NEW_DATADIR}/PG_VERSION" ]]; then + warn "Docelowy katalog danych już istnieje: ${NEW_DATADIR}" + if ask_yes_no "Usunąć istniejący klaster docelowy i zainicjalizować ponownie?" "n"; then + [[ "$NEW_DATADIR" == /var/lib/pgsql/*/data ]] || die "Niebezpieczna ścieżka NEW_DATADIR: ${NEW_DATADIR}" + systemctl stop "$NEW_SERVICE" >/dev/null 2>&1 || true + rm -rf "${NEW_DATADIR:?}/"* + info "Wyczyszczono istniejący katalog danych: ${NEW_DATADIR}" + else + die "Docelowy katalog danych już istnieje: ${NEW_DATADIR}. Przerwano." + fi + fi + + if [[ ! -d "$NEW_DATADIR" ]]; then + mkdir -p "$NEW_DATADIR" + fi + chown postgres:postgres "$NEW_DATADIR" + chmod 700 "$NEW_DATADIR" + + local initdb_bin="${NEW_BINDIR}/initdb" + [[ -x "$initdb_bin" ]] || die "Brak narzędzia initdb: ${initdb_bin}" + + local initdb_args=("-D" "$NEW_DATADIR" "$NEW_INITDB_CHECKSUM_FLAG" "-U" "$INITDB_SUPERUSER") + sudo -u postgres "$initdb_bin" "${initdb_args[@]}" || die "Inicjalizacja klastra PostgreSQL ${TARGET_VERSION} nie powiodła się." +} + +migrate_postgresql_conf() { + local src="$1" + local dst="$2" + local tmp_file + tmp_file="$(mktemp)" + + cp -p "$dst" "${dst}.preupgrade.$(date +%s)" + + { + cat "$dst" + echo "" + echo "# --- Migrated settings from PostgreSQL ${CURRENT_VERSION} on $(date) ---" + awk ' + function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s } + { + line = $0 + t = trim(line) + if (t == "" || t ~ /^#/) next + if (t ~ /^include([[:space:]]|_if_exists|_dir)/) { print line; next } + if (index(t, "=") == 0) next + split(t, a, "=") + key = tolower(trim(a[1])) + if (key ~ /^(data_directory|hba_file|ident_file|external_pid_file|config_file)$/) next + print line + } + ' "$src" + echo "# --- End migrated settings ---" + } > "$tmp_file" + + mv "$tmp_file" "$dst" + chown postgres:postgres "$dst" + chmod 600 "$dst" +} + +copy_hba_conf() { + cp -p "$NEW_HBA" "${NEW_HBA}.preupgrade.$(date +%s)" + cp -p "$OLD_HBA" "$NEW_HBA" + chown postgres:postgres "$NEW_HBA" + chmod 600 "$NEW_HBA" +} + +stop_clusters() { + sekcja "Zatrzymywanie usług PostgreSQL" + if systemctl is-active --quiet "$OLD_SERVICE"; then + systemctl stop "$OLD_SERVICE" || die "Nie udało się zatrzymać ${OLD_SERVICE}." + else + info "Usługa ${OLD_SERVICE} już jest zatrzymana." + fi + + systemctl stop "$NEW_SERVICE" >/dev/null 2>&1 || true +} + +run_pg_upgrade() { + sekcja "Aktualizacja danych przez pg_upgrade" + local jobs + jobs="$(nproc 2>/dev/null || echo 2)" + [[ "$jobs" =~ ^[0-9]+$ ]] || jobs=2 + (( jobs >= 1 )) || jobs=1 + + local workdir="/var/lib/pgsql/upgrade_${CURRENT_VERSION}_to_${TARGET_VERSION}_$(date +%Y%m%d_%H%M%S)" + PG_UPGRADE_WORKDIR="$workdir" + mkdir -p "$workdir" + chown postgres:postgres "$workdir" + chmod 700 "$workdir" + + sudo -u postgres env \ + PGUSER="$PGUSER_VAL" \ + PGPASSWORD="$PGPASSWORD_VAL" \ + bash -c "cd '$workdir' && '$NEW_BINDIR/pg_upgrade' -U '$PGUSER_VAL' -b '$OLD_BINDIR' -B '$NEW_BINDIR' -d '$OLD_DATADIR' -D '$NEW_DATADIR' -j '$jobs'" \ + || die "pg_upgrade zakończył się błędem. Sprawdź logi w: ${workdir}" + + if [[ -x "${workdir}/delete_old_cluster.sh" ]]; then + warn "Skrypt do usunięcia starego klastra dostępny: ${workdir}/delete_old_cluster.sh" + DELETE_OLD_CLUSTER_CMD="sudo -u postgres '${workdir}/delete_old_cluster.sh'" + fi +} + +restore_logical_backup_to_new_cluster() { + sekcja "Odtwarzanie backupu logicznego w nowym klastrze" + + [[ -n "$BACKUP_CLUSTER_FILE" ]] || die "Brak pliku pełnego dumpa do odtworzenia." + [[ -f "$BACKUP_CLUSTER_FILE" ]] || die "Plik dumpa nie istnieje: ${BACKUP_CLUSTER_FILE}" + + local psql_bin="${NEW_BINDIR}/psql" + [[ -x "$psql_bin" ]] || die "Brak narzędzia psql dla nowej wersji: ${psql_bin}" + [[ -n "$RESTORE_MAINT_DB" ]] || die "Brak nazwy bazy serwisowej restore." + + local sanitize_restore_awk + sanitize_restore_awk=' + function lower(s) { return tolower(s) } + { + line = $0 + l = lower(line) + if (l ~ /^[[:space:]]*drop[[:space:]]+role[[:space:]]+if[[:space:]]+exists[[:space:]]+"?postgres"?[[:space:]]*;[[:space:]]*$/) next + if (l ~ /^[[:space:]]*create[[:space:]]+role[[:space:]]+"?postgres"?([[:space:]]|;)/) next + if (l ~ /^[[:space:]]*comment[[:space:]]+on[[:space:]]+role[[:space:]]+"?postgres"?([[:space:]]|;)/) next + print line + } + ' + + info "Przywracam pełny dump klastra z pliku: ${BACKUP_CLUSTER_FILE}" + gzip -dc "$BACKUP_CLUSTER_FILE" \ + | awk "$sanitize_restore_awk" \ + | sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d "$RESTORE_MAINT_DB" \ + || die "Odtwarzanie dumpa do nowego klastra nie powiodło się." +} + +cleanup_restore_maintenance_database() { + [[ "$UPGRADE_MODE" == "backup_restore" ]] || return 0 + [[ -n "$RESTORE_MAINT_DB" ]] || return 0 + [[ "$RESTORE_MAINT_DB" =~ ^da_restore_maint_[0-9]{14}$ ]] || { + warn "Pomijam usuwanie bazy serwisowej o nieoczekiwanej nazwie: ${RESTORE_MAINT_DB}" + return 0 + } + + sekcja "Usuwanie tymczasowej bazy serwisowej restore" + + local psql_bin="${NEW_BINDIR}/psql" + [[ -x "$psql_bin" ]] || die "Brak narzędzia psql dla nowej wersji: ${psql_bin}" + + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "REVOKE CONNECT ON DATABASE \"${RESTORE_MAINT_DB}\" FROM PUBLIC;" \ + || true + + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${RESTORE_MAINT_DB}' AND pid <> pg_backend_pid();" \ + || true + + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "DROP DATABASE IF EXISTS \"${RESTORE_MAINT_DB}\";" \ + || die "Nie udało się usunąć tymczasowej bazy serwisowej: ${RESTORE_MAINT_DB}" + + info "Usunięto tymczasową bazę serwisową restore: ${RESTORE_MAINT_DB}" +} + +cleanup_temporary_bootstrap_superuser() { + [[ "$UPGRADE_MODE" == "backup_restore" ]] || return 0 + [[ "$INITDB_SUPERUSER" != "postgres" ]] || return 0 + [[ "$INITDB_SUPERUSER" =~ ^da_upgrade_bootstrap_[0-9]{14}$ ]] || { + warn "Pomijam usuwanie superusera bootstrap o nieoczekiwanej nazwie: ${INITDB_SUPERUSER}" + return 0 + } + + sekcja "Usuwanie tymczasowego superusera bootstrap" + + local psql_bin="${NEW_BINDIR}/psql" + [[ -x "$psql_bin" ]] || die "Brak narzędzia psql dla nowej wersji: ${psql_bin}" + + local keep_role="" + keep_role="$( + sudo -u postgres "$psql_bin" -X -At -U "$INITDB_SUPERUSER" -d template1 \ + -c "SELECT rolname FROM pg_roles WHERE rolsuper AND rolcanlogin AND rolname <> '${INITDB_SUPERUSER}' ORDER BY (rolname='postgres') DESC, rolname LIMIT 1;" + )" + [[ -n "$keep_role" ]] || die "Brak dostępnej roli superuser (login) do przejęcia własności po ${INITDB_SUPERUSER}." + + [[ "$keep_role" =~ ^[A-Za-z0-9_]{1,63}$ ]] || die "Nieprawidłowa rola docelowa przejęcia własności: ${keep_role}" + + info "Rola przejmująca własność: ${keep_role}" + + mapfile -t all_dbs < <( + sudo -u postgres "$psql_bin" -X -At -U "$INITDB_SUPERUSER" -d template1 \ + -c "SELECT datname FROM pg_database WHERE datallowconn AND datname <> 'template0' ORDER BY datname;" + ) + + local db + for db in "${all_dbs[@]}"; do + [[ -n "$db" ]] || continue + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d "$db" \ + -c "REASSIGN OWNED BY \"${INITDB_SUPERUSER}\" TO \"${keep_role}\";" \ + || die "Nie udało się wykonać REASSIGN OWNED w bazie ${db}." + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d "$db" \ + -c "DROP OWNED BY \"${INITDB_SUPERUSER}\";" \ + || die "Nie udało się wykonać DROP OWNED w bazie ${db}." + done + + mapfile -t owned_dbs < <( + sudo -u postgres "$psql_bin" -X -At -U "$INITDB_SUPERUSER" -d template1 \ + -c "SELECT datname FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = '${INITDB_SUPERUSER}') AND datname <> 'template0' ORDER BY datname;" + ) + + for db in "${owned_dbs[@]}"; do + [[ -n "$db" ]] || continue + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "ALTER DATABASE \"${db}\" OWNER TO \"${keep_role}\";" \ + || die "Nie udało się zmienić właściciela bazy ${db}." + done + + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$keep_role" -d template1 \ + -c "DROP ROLE \"${INITDB_SUPERUSER}\";" \ + || die "Nie udało się usunąć tymczasowego superusera: ${INITDB_SUPERUSER}." + + info "Usunięto tymczasowego superusera: ${INITDB_SUPERUSER}" +} + +sync_admin_role_password_after_restore() { + [[ "$UPGRADE_MODE" == "backup_restore" ]] || return 0 + + sekcja "Synchronizacja hasła roli administracyjnej po restore" + + local psql_bin="${NEW_BINDIR}/psql" + [[ -x "$psql_bin" ]] || die "Brak narzędzia psql dla nowej wersji: ${psql_bin}" + [[ -n "$PGUSER_VAL" ]] || die "Brak roli administracyjnej PostgreSQL (PG_USER)." + [[ "$PGUSER_VAL" =~ ^[A-Za-z0-9_@.-]{1,63}$ ]] || die "Nieprawidłowa nazwa roli administracyjnej: ${PGUSER_VAL}" + + local role_name_sql role_password_sql + role_name_sql="$(printf "%s" "$PGUSER_VAL" | sed "s/'/''/g")" + role_password_sql="$(printf "%s" "$PGPASSWORD_VAL" | sed "s/'/''/g")" + + local role_exists="" + role_exists="$( + sudo -u postgres "$psql_bin" -X -At -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "SELECT 1 FROM pg_roles WHERE rolname = '${role_name_sql}' LIMIT 1;" + )" + [[ "$role_exists" == "1" ]] || die "Rola administracyjna '${PGUSER_VAL}' nie istnieje po restore." + + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "DO \$\$ BEGIN EXECUTE format('ALTER ROLE %I WITH LOGIN PASSWORD %L', '${role_name_sql}', '${role_password_sql}'); END \$\$;" \ + || die "Nie udało się zsynchronizować hasła roli '${PGUSER_VAL}' po restore." + + info "Zsynchronizowano hasło roli ${PGUSER_VAL} z danymi z ${CRED_FILE}." +} + +prepare_fresh_cluster_for_full_restore() { + sekcja "Przygotowanie świeżego klastra do pełnego restore" + + local psql_bin="${NEW_BINDIR}/psql" + [[ -x "$psql_bin" ]] || die "Brak narzędzia psql dla nowej wersji: ${psql_bin}" + [[ -n "$RESTORE_MAINT_DB" ]] || die "Brak nazwy tymczasowej bazy serwisowej do restore." + [[ "$RESTORE_MAINT_DB" =~ ^[a-z0-9_]{1,63}$ ]] || die "Nieprawidłowa nazwa bazy serwisowej restore: ${RESTORE_MAINT_DB}" + + # W trybie backup+restore dump jest tworzony bez wykluczania baz + # (z opcjami --clean --if-exists), więc nie usuwamy ręcznie bazy postgres. + info "Weryfikuję dostęp do świeżego klastra przed odtworzeniem dumpa." + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "SELECT 1;" \ + || die "Nie udało się przygotować nowego klastra do restore." + + info "Tworzę tymczasową bazę serwisową restore: ${RESTORE_MAINT_DB}" + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "DROP DATABASE IF EXISTS \"${RESTORE_MAINT_DB}\";" \ + || die "Nie udało się usunąć poprzedniej bazy serwisowej restore." + sudo -u postgres "$psql_bin" -X -v ON_ERROR_STOP=1 -U "$INITDB_SUPERUSER" -d template1 \ + -c "CREATE DATABASE \"${RESTORE_MAINT_DB}\";" \ + || die "Nie udało się utworzyć bazy serwisowej restore." +} + +restart_new_service_after_config_migration() { + sekcja "Restart usługi PostgreSQL po migracji konfiguracji" + systemctl restart "$NEW_SERVICE" || die "Nie udało się zrestartować usługi ${NEW_SERVICE}." + systemctl is-active --quiet "$NEW_SERVICE" || die "Usługa ${NEW_SERVICE} nie działa po restarcie." + info "Usługa ${NEW_SERVICE} została zrestartowana po migracji konfiguracji." +} + +run_post_upgrade_maintenance() { + sekcja "Statystyki po aktualizacji (vacuumdb)" + + local analyze_script="" + if [[ -n "$PG_UPGRADE_WORKDIR" ]]; then + analyze_script="${PG_UPGRADE_WORKDIR}/analyze_new_cluster.sh" + fi + + if [[ -n "$analyze_script" && -x "$analyze_script" ]]; then + info "Uruchamiam ${analyze_script}." + if sudo -u postgres "$analyze_script"; then + return 0 + fi + warn "analyze_new_cluster.sh zakończył się błędem. Uruchamiam fallback vacuumdb." + fi + + local vacuumdb_bin="${NEW_BINDIR}/vacuumdb" + if [[ ! -x "$vacuumdb_bin" ]]; then + warn "Brak narzędzia vacuumdb (${vacuumdb_bin}). Pomijam aktualizację statystyk." + return 0 + fi + + info "Uruchamiam: vacuumdb --all --analyze-in-stages --missing-stats-only" + sudo -u postgres env \ + PGHOST="$PGHOST_VAL" \ + PGPORT="$PGPORT_VAL" \ + PGUSER="$PGUSER_VAL" \ + PGPASSWORD="$PGPASSWORD_VAL" \ + "$vacuumdb_bin" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$PGUSER_VAL" --all --analyze-in-stages --missing-stats-only \ + || warn "Pierwszy etap vacuumdb zakończył się błędem." + + info "Uruchamiam: vacuumdb --all --analyze-only" + sudo -u postgres env \ + PGHOST="$PGHOST_VAL" \ + PGPORT="$PGPORT_VAL" \ + PGUSER="$PGUSER_VAL" \ + PGPASSWORD="$PGPASSWORD_VAL" \ + "$vacuumdb_bin" -h "$PGHOST_VAL" -p "$PGPORT_VAL" -U "$PGUSER_VAL" --all --analyze-only \ + || warn "Drugi etap vacuumdb zakończył się błędem." +} + +generate_remove_old_postgres_script() { + sekcja "Generowanie skryptu usuwania starej wersji PostgreSQL" + + local delete_script="" + if [[ -n "$PG_UPGRADE_WORKDIR" ]]; then + delete_script="${PG_UPGRADE_WORKDIR}/delete_old_cluster.sh" + fi + + cat > "$REMOVE_OLD_SCRIPT_PATH" <&2 + exit 1 +fi + +echo "Stara wersja PostgreSQL do usunięcia: \${OLD_VERSION}" +read -r -p "Czy na pewno usunąć starą wersję PostgreSQL \${OLD_VERSION} (dane i pakiety)? [t/N]: " answer +case "\${answer,,}" in + t|tak|y|yes) ;; + *) echo "[INFO] Anulowano usuwanie starej wersji."; exit 0 ;; +esac + +if ! systemctl is-active --quiet "\${NEW_SERVICE}"; then + echo "[BŁĄD] Nowa usługa \${NEW_SERVICE} nie działa. Przerywam." >&2 + exit 1 +fi + +if [[ -n "\${DELETE_OLD_CLUSTER_SCRIPT}" && -x "\${DELETE_OLD_CLUSTER_SCRIPT}" ]]; then + echo "[INFO] Uruchamiam: \${DELETE_OLD_CLUSTER_SCRIPT}" + sudo -u postgres "\${DELETE_OLD_CLUSTER_SCRIPT}" +else + echo "[INFO] Usuwam katalog danych starego klastra: \${OLD_DATADIR}" + systemctl stop "\${OLD_SERVICE}" >/dev/null 2>&1 || true + if [[ -d "\${OLD_DATADIR}" ]]; then + rm -rf "\${OLD_DATADIR}" + else + echo "[WARN] Katalog danych nie istnieje: \${OLD_DATADIR}" + fi +fi + +systemctl disable "\${OLD_SERVICE}" >/dev/null 2>&1 || true + +mapfile -t old_pkgs < <(rpm -qa --qf '%{NAME}\n' | grep -E "^postgresql\${OLD_VERSION}($|-)" | sort -u || true) +if [[ \${#old_pkgs[@]} -gt 0 ]]; then + echo "[INFO] Usuwam pakiety starej wersji: \${old_pkgs[*]}" + dnf remove -y "\${old_pkgs[@]}" +else + echo "[WARN] Brak pakietów PostgreSQL \${OLD_VERSION} do usunięcia." +fi + +echo "[INFO] Zakończono usuwanie starej wersji PostgreSQL \${OLD_VERSION}." +EOF + + chmod 700 "$REMOVE_OLD_SCRIPT_PATH" + chown root:root "$REMOVE_OLD_SCRIPT_PATH" || true + + REMOVE_OLD_SCRIPT_RESULT="wygenerowano: ${REMOVE_OLD_SCRIPT_PATH}" + DELETE_OLD_CLUSTER_CMD="$REMOVE_OLD_SCRIPT_PATH" + info "Wygenerowano skrypt: ${REMOVE_OLD_SCRIPT_PATH} (uprawnienia 700)." +} + +start_new_service() { + sekcja "Uruchamianie nowej usługi PostgreSQL" + systemctl enable "$NEW_SERVICE" || die "Nie udało się włączyć usługi ${NEW_SERVICE}." + systemctl start "$NEW_SERVICE" || die "Nie udało się uruchomić usługi ${NEW_SERVICE}." + systemctl is-active --quiet "$NEW_SERVICE" || die "Usługa ${NEW_SERVICE} nie jest aktywna po uruchomieniu." + systemctl disable "$OLD_SERVICE" >/dev/null 2>&1 || true + info "Usługa ${NEW_SERVICE} działa." +} + +update_pg_config_symlink() { + local src="${NEW_BINDIR}/pg_config" + local dst="/usr/local/bin/pg_config" + if [[ -x "$src" ]]; then + ln -sf "$src" "$dst" + info "Zaktualizowano symlink: ${dst} -> ${src}" + fi + cb_pgsql_configure_runtime_libpq "$NEW_PREFIX" +} + +update_php_config_and_build() { + [[ "$PHP_UPDATE_NEEDED" == "tak" ]] || return 0 + [[ -x "${CB_DIR}/build" ]] || { warn "Brak ${CB_DIR}/build. Pomijam aktualizację PHP."; return 0; } + + sekcja "Aktualizacja ścieżek PostgreSQL w configure.phpXX i rekompilacja PHP" + local ts + ts="$(date +%s)" + local f + local version + + for f in "${PHP_FILES_TO_UPDATE[@]}"; do + cp -p "$f" "${f}.bak.${ts}" + sed -i "s|${OLD_PREFIX}|${NEW_PREFIX}|g" "$f" + info "Zaktualizowano: $f" + done + + cd "$CB_DIR" + if [[ ${#PHP_VERSIONS_TO_REBUILD[@]} -eq 0 ]]; then + LD_LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ + LD_PRELOAD="${DA_PG_RUNTIME_LIBPQ}${LD_PRELOAD:+:${LD_PRELOAD}}" \ + LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LIBRARY_PATH:+:${LIBRARY_PATH}}" \ + PKG_CONFIG_PATH="${DA_PG_RUNTIME_LIBDIR}/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" \ + LDFLAGS="-L${DA_PG_RUNTIME_LIBDIR} ${LDFLAGS:-}" \ + CPPFLAGS="-I${NEW_PREFIX}/include ${CPPFLAGS:-}" \ + ./build php || die "Rekompilacja PHP nie powiodła się." + else + mapfile -t PHP_VERSIONS_TO_REBUILD < <(printf '%s\n' "${PHP_VERSIONS_TO_REBUILD[@]}" | sort -u) + for version in "${PHP_VERSIONS_TO_REBUILD[@]}"; do + LD_LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ + LD_PRELOAD="${DA_PG_RUNTIME_LIBPQ}${LD_PRELOAD:+:${LD_PRELOAD}}" \ + LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LIBRARY_PATH:+:${LIBRARY_PATH}}" \ + PKG_CONFIG_PATH="${DA_PG_RUNTIME_LIBDIR}/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" \ + LDFLAGS="-L${DA_PG_RUNTIME_LIBDIR} ${LDFLAGS:-}" \ + CPPFLAGS="-I${NEW_PREFIX}/include ${CPPFLAGS:-}" \ + ./build php "$version" || die "Rekompilacja PHP ${version} nie powiodła się." + done + fi + ./build rewrite_confs || warn "rewrite_confs zakończył się błędem." + cd - >/dev/null +} + +final_summary() { + sekcja "Aktualizacja PostgreSQL zakończona" + printf "Stara wersja PostgreSQL:\t%s\n" "$CURRENT_VERSION" + printf "Nowa wersja PostgreSQL:\t%s\n" "$TARGET_VERSION" + printf "Tryb aktualizacji:\t%s\n" "$UPGRADE_MODE" + printf "Nowy katalog danych:\t%s\n" "$NEW_DATADIR" + printf "Backup wykonany:\t%s\n" "$DO_BACKUP" + if [[ "$DO_BACKUP" == "tak" ]]; then + printf "Rodzaj backupu:\t%s\n" "$BACKUP_RESULT" + fi + if [[ -n "$BACKUP_DIR" ]]; then + printf "Katalog backupu:\t%s\n" "$BACKUP_DIR" + fi + if [[ -n "$DELETE_OLD_CLUSTER_CMD" ]]; then + printf "Polecenie usunięcia starego klastra:\t%s\n" "$DELETE_OLD_CLUSTER_CMD" + fi + printf "Skrypt usuwania starej wersji:\t%s\n" "$REMOVE_OLD_SCRIPT_RESULT" + printf "Aktualizacja PHP:\t%s\n" "$PHP_UPDATE_NEEDED" + echo "" + info "Pełny log: ${LOGFILE}" +} + +main() { + detect_os + detect_installed_versions + detect_current_version + choose_target_version + choose_upgrade_mode + prepare_paths + load_pg_credentials + + if [[ "$UPGRADE_MODE" == "backup_restore" ]]; then + DO_BACKUP="tak" + info "Tryb backup+restore wymaga wykonania backupu logicznego. Ustawiono: backup=tak." + else + if ask_yes_no "Wykonać pełny backup wszystkich baz (z rolami/użytkownikami) przed aktualizacją?" "t"; then + DO_BACKUP="tak" + else + DO_BACKUP="nie" + fi + fi + + detect_php_update_need + print_summary_and_confirm + ensure_sudo_available + detect_old_cluster_checksum_mode + + # Bezpieczeństwo: najpierw pełny backup, dopiero potem działania modyfikujące. + run_full_backup + + if [[ "$UPGRADE_MODE" == "pg_upgrade" ]]; then + install_target_packages + init_new_cluster + stop_clusters + run_pg_upgrade + + sekcja "Migracja ustawień postgresql.conf i pg_hba.conf" + migrate_postgresql_conf "$OLD_CONF" "$NEW_CONF" + copy_hba_conf + + start_new_service + else + install_target_packages + init_new_cluster + stop_clusters + start_new_service + prepare_fresh_cluster_for_full_restore + restore_logical_backup_to_new_cluster + cleanup_restore_maintenance_database + cleanup_temporary_bootstrap_superuser + sync_admin_role_password_after_restore + + sekcja "Migracja ustawień postgresql.conf i pg_hba.conf" + migrate_postgresql_conf "$OLD_CONF" "$NEW_CONF" + copy_hba_conf + restart_new_service_after_config_migration + DELETE_OLD_CLUSTER_CMD="systemctl stop ${OLD_SERVICE} && rm -rf '${OLD_DATADIR}'" + fi + + if [[ -z "$DELETE_OLD_CLUSTER_CMD" ]]; then + DELETE_OLD_CLUSTER_CMD="systemctl stop ${OLD_SERVICE} && rm -rf '${OLD_DATADIR}'" + fi + + run_post_upgrade_maintenance + generate_remove_old_postgres_script + update_pg_config_symlink + update_php_config_and_build + final_summary +} + +main "$@" diff --git a/scripts/setup/recompile_php.sh b/scripts/setup/recompile_php.sh new file mode 100755 index 0000000..eda7e51 --- /dev/null +++ b/scripts/setup/recompile_php.sh @@ -0,0 +1,520 @@ +#!/bin/bash +# ============================================================================= +# recompile_php.sh +# Weryfikacja i rekompilacja wersji PHP w DirectAdmin CustomBuild +# tak, aby każda aktywna wersja miała rozszerzenia pgsql i pdo_pgsql. +# ============================================================================= + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +sekcja() { echo -e "\n${BOLD}${CYAN}=== $* ===${NC}\n"; } +die() { echo -e "${RED}[BŁĄD]${NC} $*" >&2; exit 1; } + +CB_PHP_WEBSERVER="" +CB_PHP_OPTIONS_CONF="" +CB_PG_PREFIX="" +CB_PG_VERSION="" +CB_PG_CONFIG="" +DA_PG_RUNTIME_LIBDIR="" +DA_PG_RUNTIME_LIBPQ="" +declare -ag CB_PHP_VERSIONS=() +declare -ag CB_PHP_CODES=() +declare -ag CB_PHP_MODES=() + +cb_pgsql_reset_php_targets() { + CB_PHP_VERSIONS=() + CB_PHP_CODES=() + CB_PHP_MODES=() +} + +cb_pgsql_get_cb_option() { + local options_conf="$1" + local key="$2" + local value="" + + [[ -f "$options_conf" ]] || return 1 + value="$(awk -F= -v key="$key" ' + $1 == key { + sub(/^[[:space:]]+/, "", $2) + sub(/[[:space:]]+$/, "", $2) + print $2 + exit + } + ' "$options_conf")" + [[ -n "$value" ]] || return 1 + printf '%s\n' "$value" +} + +cb_pgsql_php_code_from_version() { + printf '%s\n' "${1//./}" +} + +cb_pgsql_php_version_from_code() { + local code="$1" + if [[ "$code" == *.* ]]; then + printf '%s\n' "$code" + elif [[ "$code" =~ ^[0-9]{2,3}$ ]]; then + printf '%s.%s\n' "${code:0:1}" "${code:1}" + else + printf '%s\n' "$code" + fi +} + +cb_pgsql_add_php_target() { + local version="$1" + local mode="$2" + local code="" + local idx="" + + [[ -n "$version" ]] || return 0 + code="$(cb_pgsql_php_code_from_version "$version")" + for idx in "${!CB_PHP_CODES[@]}"; do + if [[ "${CB_PHP_CODES[$idx]}" == "$code" ]]; then + return 0 + fi + done + + CB_PHP_VERSIONS+=("$version") + CB_PHP_CODES+=("$code") + CB_PHP_MODES+=("${mode:-php-fpm}") +} + +cb_pgsql_detect_webserver() { + local cb_dir="$1" + + CB_PHP_OPTIONS_CONF="${cb_dir}/options.conf" + CB_PHP_WEBSERVER="apache" + + if [[ -f "$CB_PHP_OPTIONS_CONF" ]]; then + CB_PHP_WEBSERVER="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "webserver" || true)" + [[ -n "$CB_PHP_WEBSERVER" ]] || CB_PHP_WEBSERVER="apache" + fi +} + +cb_pgsql_collect_php_targets() { + local cb_dir="$1" + local slot="" + local release="" + local mode="" + local default_mode="" + + cb_pgsql_reset_php_targets + cb_pgsql_detect_webserver "$cb_dir" + + # CloudLinux może używać lsphp także przy webserver=apache. + # Dlatego najpierw honorujemy phpN_mode/php1_mode z options.conf, + # a dopiero przy ich braku stosujemy domyślny tryb wynikający z webserwera. + case "$CB_PHP_WEBSERVER" in + litespeed|openlitespeed) default_mode="lsphp" ;; + *) default_mode="php-fpm" ;; + esac + + for slot in 1 2 3 4 5 6 7 8 9; do + release="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php${slot}_release" || true)" + mode="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php${slot}_mode" || true)" + [[ -n "$mode" ]] || mode="$(cb_pgsql_get_cb_option "$CB_PHP_OPTIONS_CONF" "php1_mode" || true)" + [[ -n "$mode" ]] || mode="$default_mode" + case "${release,,}" in + ""|no|off) continue ;; + esac + case "${mode,,}" in + no|off) continue ;; + esac + cb_pgsql_add_php_target "$release" "$mode" + done +} + +cb_pgsql_build_file_candidates() { + local cb_dir="$1" + local mode="$2" + local code="$3" + local -n out_ref="$4" + + out_ref=() + if [[ "$mode" == "lsphp" ]]; then + case "$CB_PHP_WEBSERVER" in + litespeed) + out_ref+=("${cb_dir}/configure/litespeed/configure.php${code}") + ;; + openlitespeed) + out_ref+=("${cb_dir}/configure/openlitespeed/configure.php${code}") + ;; + esac + out_ref+=("${cb_dir}/configure/lsphp/configure.lsphp${code}") + out_ref+=("${cb_dir}/configure/lsphp/configure.php${code}") + fi + out_ref+=("${cb_dir}/configure/php/configure.php${code}") +} + +cb_pgsql_resolve_config_paths() { + local cb_dir="$1" + local version="$2" + local mode="$3" + local source_ref_name="$4" + local custom_ref_name="$5" + local code="" + local -a candidates=() + local candidate="" + + code="$(cb_pgsql_php_code_from_version "$version")" + printf -v "$source_ref_name" '%s' "" + printf -v "$custom_ref_name" '%s' "" + + cb_pgsql_build_file_candidates "$cb_dir" "$mode" "$code" candidates + + for candidate in "${candidates[@]}"; do + if [[ -f "$candidate" ]]; then + printf -v "$source_ref_name" '%s' "$candidate" + printf -v "$custom_ref_name" '%s' "$(printf '%s\n' "$candidate" | sed "s#^${cb_dir}/configure/#${cb_dir}/custom/#")" + return 0 + fi + done + + for candidate in "${candidates[@]}"; do + candidate="$(printf '%s\n' "$candidate" | sed "s#^${cb_dir}/configure/#${cb_dir}/custom/#")" + if [[ -f "$candidate" ]]; then + printf -v "$source_ref_name" '%s' "$candidate" + printf -v "$custom_ref_name" '%s' "$candidate" + return 0 + fi + done + + return 1 +} + +cb_pgsql_insert_flags() { + local config_file="$1" + local pg_prefix="$2" + local tmp_file="${config_file}.tmp" + + sed -i '/--with-pgsql=/d; /--with-pdo-pgsql=/d' "$config_file" + + if grep -q -- '--with-pdo-mysql' "$config_file"; then + awk -v prefix="$pg_prefix" ' + /--with-pdo-mysql/ { + print + print "\t--with-pgsql=" prefix " \\" + print "\t--with-pdo-pgsql=" prefix " \\" + next + } + { print } + ' "$config_file" > "$tmp_file" + else + awk -v prefix="$pg_prefix" ' + /^[[:space:]]*--with/ { last = NR } + { lines[NR] = $0 } + END { + if (last == 0) { + for (i = 1; i <= NR; i++) print lines[i] + exit + } + for (i = 1; i <= NR; i++) { + print lines[i] + if (i == last) { + print "\t--with-pgsql=" prefix " \\" + print "\t--with-pdo-pgsql=" prefix " \\" + } + } + } + ' "$config_file" > "$tmp_file" + fi + + mv "$tmp_file" "$config_file" +} + +cb_pgsql_prepare_custom_config() { + local cb_dir="$1" + local version="$2" + local mode="$3" + local pg_prefix="$4" + local source_conf="" + local custom_conf="" + local ts="" + + if ! cb_pgsql_resolve_config_paths "$cb_dir" "$version" "$mode" source_conf custom_conf; then + warn "Nie znaleziono configure dla PHP ${version} (tryb ${mode}, webserver ${CB_PHP_WEBSERVER})." + return 1 + fi + + mkdir -p "$(dirname "$custom_conf")" + ts="$(date +%s)" + + if [[ "$source_conf" != "$custom_conf" && ! -f "$custom_conf" ]]; then + cp -p "$source_conf" "$custom_conf" + elif [[ -f "$custom_conf" ]]; then + cp -p "$custom_conf" "${custom_conf}.bak.${ts}" + fi + + cb_pgsql_insert_flags "$custom_conf" "$pg_prefix" + chmod +x "$custom_conf" || true + info "Przygotowano configure dla PHP ${version}: ${custom_conf}" + return 0 +} + +cb_pgsql_configure_runtime_libpq() { + local pg_prefix="$1" + local pg_libdir="${pg_prefix}/lib" + local libpq_so="${pg_libdir}/libpq.so.5" + local ld_conf="/etc/ld.so.conf.d/00-da-postgresql-libpq.conf" + local old_ld_conf="/etc/ld.so.conf.d/da-postgresql-libpq.conf" + local preferred="" + + [[ -d "$pg_libdir" ]] || die "Nie znaleziono katalogu bibliotek PostgreSQL: ${pg_libdir}" + [[ -r "$libpq_so" ]] || die "Nie znaleziono biblioteki libpq: ${libpq_so}" + + printf '%s\n' "$pg_libdir" > "$ld_conf" + chmod 644 "$ld_conf" + [[ "$old_ld_conf" == "$ld_conf" ]] || rm -f "$old_ld_conf" + + if command -v ldconfig >/dev/null 2>&1; then + ldconfig || die "Nie udało się odświeżyć cache bibliotek przez ldconfig." + preferred="$(ldconfig -p 2>/dev/null | awk '/libpq\.so\.5/{print $NF; exit}')" + if [[ -n "$preferred" && "$preferred" != "$libpq_so" ]]; then + warn "Domyślna libpq to ${preferred}; wymuszę właściwą bibliotekę przez LD_PRELOAD podczas kompilacji PHP." + fi + else + warn "Brak ldconfig. Kontynuuję z LD_LIBRARY_PATH." + fi + + DA_PG_RUNTIME_LIBDIR="$pg_libdir" + DA_PG_RUNTIME_LIBPQ="$libpq_so" + info "Skonfigurowano bibliotekę runtime PostgreSQL: ${pg_libdir}" +} + +cb_pgsql_detect_pg_prefix() { + local candidate="" + + CB_PG_PREFIX="" + CB_PG_VERSION="" + CB_PG_CONFIG="" + + if [[ -x /usr/local/bin/pg_config ]]; then + candidate="$(readlink -f /usr/local/bin/pg_config 2>/dev/null || true)" + if [[ -x "$candidate" ]]; then + CB_PG_CONFIG="$candidate" + fi + fi + + if [[ -z "$CB_PG_CONFIG" ]]; then + candidate="$(ls -1 /usr/pgsql-*/bin/pg_config 2>/dev/null | sort -V | tail -n1 || true)" + if [[ -n "$candidate" && -x "$candidate" ]]; then + CB_PG_CONFIG="$candidate" + fi + fi + + [[ -n "$CB_PG_CONFIG" ]] || return 1 + + CB_PG_PREFIX="$(cd "$(dirname "$CB_PG_CONFIG")/.." && pwd)" + if [[ "$CB_PG_PREFIX" =~ /usr/pgsql-([0-9]+)$ ]]; then + CB_PG_VERSION="${BASH_REMATCH[1]}" + fi + [[ -n "$CB_PG_PREFIX" ]] || return 1 + return 0 +} + +cb_pgsql_find_php_bin_for_version() { + local version="$1" + local code="" + local candidate="" + + code="$(cb_pgsql_php_code_from_version "$version")" + for candidate in \ + "/usr/local/php${code}/bin/php" \ + "/usr/local/php${code}/bin/lsphp" \ + "/usr/local/lsphp${code}/bin/lsphp" \ + "/usr/local/php${code}/bin/php-cgi"; do + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +cb_pgsql_probe_php_extensions() { + local php_bin="$1" + local pgsql_ref_name="$2" + local pdo_ref_name="$3" + local modules="" + local modules_lc="" + local probe_pgsql_status="NIE" + local probe_pdo_status="NIE" + + modules="$("$php_bin" -m 2>/dev/null || true)" + if [[ -z "$modules" ]]; then + probe_pgsql_status="BŁĄD" + probe_pdo_status="BŁĄD" + printf -v "$pgsql_ref_name" '%s' "$probe_pgsql_status" + printf -v "$pdo_ref_name" '%s' "$probe_pdo_status" + return 1 + fi + + modules_lc="$(printf '%s\n' "$modules" | tr '[:upper:]' '[:lower:]')" + if printf '%s\n' "$modules_lc" | grep -qx 'pgsql'; then + probe_pgsql_status="OK" + fi + if printf '%s\n' "$modules_lc" | grep -qx 'pdo_pgsql'; then + probe_pdo_status="OK" + fi + + printf -v "$pgsql_ref_name" '%s' "$probe_pgsql_status" + printf -v "$pdo_ref_name" '%s' "$probe_pdo_status" + return 0 +} + +cb_pgsql_render_extensions_table() { + local found=0 + local idx="" + local version="" + local php_bin="" + local pgsql_status="" + local pdo_pgsql_status="" + + echo "+------------+-------+-----------+" + printf "| %-10s | %-5s | %-9s |\n" "Wersja PHP" "PGSQL" "PDO_PGSQL" + echo "+------------+-------+-----------+" + + for idx in "${!CB_PHP_VERSIONS[@]}"; do + version="${CB_PHP_VERSIONS[$idx]}" + php_bin="$(cb_pgsql_find_php_bin_for_version "$version" || true)" + if [[ -z "$php_bin" ]]; then + printf "| %-10s | %-5s | %-9s |\n" "PHP ${version}" "BRAK" "BRAK" + found=1 + continue + fi + + cb_pgsql_probe_php_extensions "$php_bin" pgsql_status pdo_pgsql_status >/dev/null 2>&1 || true + printf "| %-10s | %-5s | %-9s |\n" "PHP ${version}" "$pgsql_status" "$pdo_pgsql_status" + found=1 + done + + if [[ $found -eq 1 ]]; then + echo "+------------+-------+-----------+" + else + warn "Nie znaleziono wersji PHP do raportu." + fi +} + +[[ $EUID -eq 0 ]] || die "Skrypt musi być uruchomiony jako root." + +LOGFILE="/var/log/recompile_php_$(date +%Y%m%d_%H%M%S).log" +exec 1> >(tee >(sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' >> "$LOGFILE")) 2>&1 +info "Pełny log zapisany w: $LOGFILE" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CB_DIR="/usr/local/directadmin/custombuild" + +[[ -x "${CB_DIR}/build" ]] || die "Nie znaleziono DirectAdmin CustomBuild: ${CB_DIR}/build" + +sekcja "Wykrywanie środowiska DirectAdmin" + +cb_pgsql_collect_php_targets "$CB_DIR" +[[ ${#CB_PHP_VERSIONS[@]} -gt 0 ]] || die "Nie wykryto aktywnych wersji PHP w DirectAdmin CustomBuild." + +info "Web serwer: ${CB_PHP_WEBSERVER}" +info "Wykryte wersje PHP: $(IFS=', '; echo "${CB_PHP_VERSIONS[*]}")" + +sekcja "Wykrywanie PostgreSQL dla kompilacji PHP" + +cb_pgsql_detect_pg_prefix || die "Nie znaleziono pg_config. Zainstaluj PostgreSQL lub ustaw /usr/local/bin/pg_config." +[[ -n "$CB_PG_VERSION" ]] || die "Nie udało się ustalić wersji PostgreSQL z ${CB_PG_CONFIG}." + +info "Wykryty prefix PostgreSQL: ${CB_PG_PREFIX}" +info "Wykryta wersja PostgreSQL: ${CB_PG_VERSION}" + +dnf install -y "postgresql${CB_PG_VERSION}-devel" \ + "postgresql${CB_PG_VERSION}-libs" \ + || die "Nie udało się zainstalować postgresql${CB_PG_VERSION}-devel/libs." + +if [[ -x "$CB_PG_CONFIG" ]]; then + if [[ -e /usr/local/bin/pg_config && ! -L /usr/local/bin/pg_config ]]; then + warn "/usr/local/bin/pg_config istnieje i nie jest dowiązaniem – pomijam aktualizację symlinku." + else + ln -sf "$CB_PG_CONFIG" /usr/local/bin/pg_config + info "Zaktualizowano symlink: /usr/local/bin/pg_config -> ${CB_PG_CONFIG}" + fi +fi + +cb_pgsql_configure_runtime_libpq "$CB_PG_PREFIX" + +sekcja "Analiza rozszerzeń PHP" + +declare -a REBUILD_VERSIONS=() +declare -a REBUILD_MODES=() +for idx in "${!CB_PHP_VERSIONS[@]}"; do + php_version="${CB_PHP_VERSIONS[$idx]}" + php_mode="${CB_PHP_MODES[$idx]}" + php_bin="$(cb_pgsql_find_php_bin_for_version "$php_version" || true)" + pgsql_status="BRAK" + pdo_pgsql_status="BRAK" + + if [[ -z "$php_bin" ]]; then + warn "Nie znaleziono binarki PHP dla wersji ${php_version}. Dodaję do rekompilacji." + REBUILD_VERSIONS+=("$php_version") + REBUILD_MODES+=("$php_mode") + continue + fi + + cb_pgsql_probe_php_extensions "$php_bin" pgsql_status pdo_pgsql_status >/dev/null 2>&1 || true + if [[ "$pgsql_status" != "OK" || "$pdo_pgsql_status" != "OK" ]]; then + info "PHP ${php_version}: brakujące rozszerzenia (PGSQL=${pgsql_status}, PDO_PGSQL=${pdo_pgsql_status})" + REBUILD_VERSIONS+=("$php_version") + REBUILD_MODES+=("$php_mode") + else + info "PHP ${php_version}: rozszerzenia pgsql i pdo_pgsql są już dostępne." + fi +done + +if [[ ${#REBUILD_VERSIONS[@]} -eq 0 ]]; then + sekcja "Tabela rozszerzeń PHP" + cb_pgsql_render_extensions_table + info "Pełny log: ${LOGFILE}" + exit 0 +fi + +sekcja "Przygotowanie configure.phpXX" + +prepared_any=0 +for idx in "${!REBUILD_VERSIONS[@]}"; do + if cb_pgsql_prepare_custom_config "$CB_DIR" "${REBUILD_VERSIONS[$idx]}" "${REBUILD_MODES[$idx]}" "$CB_PG_PREFIX"; then + prepared_any=1 + fi +done + +[[ "$prepared_any" -eq 1 ]] || die "Nie udało się przygotować żadnego pliku configure dla rekompilacji PHP." + +sekcja "Rekompilacja PHP" + +cd "$CB_DIR" +for php_version in "${REBUILD_VERSIONS[@]}"; do + info "Uruchamiam: ./build php ${php_version}" + LD_LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ + LD_PRELOAD="${DA_PG_RUNTIME_LIBPQ}${LD_PRELOAD:+:${LD_PRELOAD}}" \ + LIBRARY_PATH="${DA_PG_RUNTIME_LIBDIR}${LIBRARY_PATH:+:${LIBRARY_PATH}}" \ + PKG_CONFIG_PATH="${DA_PG_RUNTIME_LIBDIR}/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" \ + LDFLAGS="-L${DA_PG_RUNTIME_LIBDIR} ${LDFLAGS:-}" \ + CPPFLAGS="-I${CB_PG_PREFIX}/include ${CPPFLAGS:-}" \ + ./build php "$php_version" \ + || die "Rekompilacja PHP ${php_version} nie powiodła się." +done + +./build rewrite_confs \ + || warn "rewrite_confs zakończył się błędem – sprawdź konfigurację ręcznie." +cd - >/dev/null + +sekcja "Tabela rozszerzeń PHP po rekompilacji" + +cb_pgsql_collect_php_targets "$CB_DIR" +cb_pgsql_render_extensions_table + +info "Pełny log: ${LOGFILE}" +exit 0 diff --git a/scripts/setup/restore_all.sh b/scripts/setup/restore_all.sh new file mode 100755 index 0000000..7752941 --- /dev/null +++ b/scripts/setup/restore_all.sh @@ -0,0 +1,211 @@ +#!/bin/bash +# ============================================================================= +# restore_all.sh +# Przywracanie wszystkich baz PostgreSQL z katalogu backupu +# wykonanego przez postgres_backup.sh +# ============================================================================= + +CREDENTIALS_FILE="/usr/local/directadmin/conf/postgresql.conf" + +# Kolory tylko na terminalu +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' + BOLD='\033[1m'; CYAN='\033[0;36m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BOLD=''; CYAN=''; NC='' +fi + +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +info() { echo -e " $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +die() { echo -e "${RED}[BŁĄD]${NC} $*" >&2; exit 1; } +sekcja() { echo -e "\n${BOLD}${CYAN}--- $* ---${NC}\n"; } + +# --------------------------------------------------------------------------- +# Sposób użycia +# --------------------------------------------------------------------------- +usage() { + cat < [--restore-users] + +Opis: + Przywraca wszystkie bazy danych z podanego katalogu backupu. + Pliki w katalogu powinny być wykonane przez postgres_backup.sh. + + Flagi: + --restore-users Przed przywracaniem baz odtworzy użytkowników, role + i ich hasła z pliku _globals.sql[.gz] (jeśli istnieje). + WYMAGANE do pełnego disaster recovery. + +Kolejność przywracania: + 1. _globals.sql[.gz] – użytkownicy i role (tylko z --restore-users) + 2. *.sql[.gz] – bazy danych (alfabetycznie, z pominięciem _globals) + +Przykłady: + $(basename "$0") /home/admin/postgres_backup/19-02-2026 + $(basename "$0") /home/admin/postgres_backup/19-02-2026 --restore-users + +Uwaga: + Każda przywracana baza zostanie usunięta i odtworzona od nowa. + Plik poświadczeń: ${CREDENTIALS_FILE} + +EOF +} + +# --------------------------------------------------------------------------- +# Parsowanie argumentów +# --------------------------------------------------------------------------- +BACKUP_DIR="" +RESTORE_USERS=false + +for ARG in "$@"; do + case "$ARG" in + --restore-users) RESTORE_USERS=true ;; + --help|-h) usage; exit 0 ;; + -*) die "Nieznana flaga: ${ARG}. Użyj --help aby zobaczyć pomoc." ;; + *) + [[ -z "$BACKUP_DIR" ]] || die "Podano więcej niż jeden katalog. Użyj --help aby zobaczyć pomoc." + BACKUP_DIR="$ARG" + ;; + esac +done + +if [[ -z "$BACKUP_DIR" ]]; then + usage + exit 0 +fi + +[[ -d "$BACKUP_DIR" ]] || die "Katalog nie istnieje: ${BACKUP_DIR}" + +# --------------------------------------------------------------------------- +# Poświadczenia +# --------------------------------------------------------------------------- +[[ -f "$CREDENTIALS_FILE" ]] \ + || die "Brak pliku poświadczeń: ${CREDENTIALS_FILE}" + +_perms=$(stat -c '%a' "$CREDENTIALS_FILE") +[[ "$_perms" == "600" || "$_perms" == "400" ]] \ + || die "Zbyt otwarte uprawnienia na ${CREDENTIALS_FILE} (${_perms}). Wymagane: 600 lub 400." + +_line=$(head -1 "$CREDENTIALS_FILE") +PGHOST=$(echo "$_line" | cut -d: -f1) +PGPORT=$(echo "$_line" | cut -d: -f2) +PGUSER=$(echo "$_line" | cut -d: -f4) + +[[ -n "$PGHOST" && -n "$PGPORT" && -n "$PGUSER" ]] \ + || die "Nieprawidłowy format pliku poświadczeń. Oczekiwany: host:port:database:user:password" + +export PGPASSFILE="$CREDENTIALS_FILE" + +# --------------------------------------------------------------------------- +# Funkcja przywracająca jeden plik +# --------------------------------------------------------------------------- +restore_file() { + local file="$1" + local init_db="$2" + + case "$file" in + *.sql.gz) + gunzip -c "$file" \ + | psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password -d "$init_db" + return ${PIPESTATUS[1]} + ;; + *.sql) + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" \ + --no-password -d "$init_db" -f "$file" + return $? + ;; + esac +} + +# --------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------- +echo "" +info "Katalog: ${BACKUP_DIR}" +info "Serwer: ${PGHOST}:${PGPORT} Użytkownik: ${PGUSER}" +info "Restore users: ${RESTORE_USERS}" +echo "" + +ERRORS=0 +RESTORED=0 + +# --------------------------------------------------------------------------- +# 1. Globals (użytkownicy, role) – tylko z flagą --restore-users +# --------------------------------------------------------------------------- +if [[ "$RESTORE_USERS" == "true" ]]; then + sekcja "Przywracanie użytkowników i ról (_globals)" + + GLOBALS_FILE="" + for _f in "${BACKUP_DIR}/_globals.sql.gz" "${BACKUP_DIR}/_globals.sql"; do + [[ -f "$_f" ]] && GLOBALS_FILE="$_f" && break + done + + if [[ -z "$GLOBALS_FILE" ]]; then + warn "Nie znaleziono pliku _globals.sql[.gz] w ${BACKUP_DIR} – pomijam użytkowników." + else + info "Plik: ${GLOBALS_FILE##*/}" + # Globals przywracamy do postgres; DROP/CREATE ROLE może generować ostrzeżenia + # jeśli role już istnieją – to normalne, kontynuujemy + if restore_file "$GLOBALS_FILE" "postgres"; then + ok "Użytkownicy i role przywrócone." + RESTORED=$((RESTORED + 1)) + else + warn "Przywracanie globals zakończone z ostrzeżeniami – sprawdź output powyżej." + ERRORS=$((ERRORS + 1)) + fi + fi +fi + +# --------------------------------------------------------------------------- +# 2. Bazy danych (pliki *.sql i *.sql.gz z pominięciem _globals.*) +# --------------------------------------------------------------------------- +sekcja "Przywracanie baz danych" + +# Zbierz pliki, posortuj alfabetycznie +mapfile -t DB_FILES < <( + find "$BACKUP_DIR" -maxdepth 1 -type f \( -name '*.sql' -o -name '*.sql.gz' \) \ + ! -name '_globals.*' \ + | sort +) + +if [[ ${#DB_FILES[@]} -eq 0 ]]; then + warn "Nie znaleziono plików .sql ani .sql.gz w katalogu ${BACKUP_DIR} (poza _globals)." + exit 0 +fi + +for FILE in "${DB_FILES[@]}"; do + DB_NAME=$(basename "$FILE" | sed 's/\.sql\.gz$//; s/\.sql$//') + + # Baza 'postgres' nie może być przywracana przez siebie (DROP DATABASE blokuje aktywne połączenie) + if [[ "$DB_NAME" == "postgres" ]]; then + INIT_DB="template1" + else + INIT_DB="postgres" + fi + + info "→ ${DB_NAME} (${FILE##*/})" + + if restore_file "$FILE" "$INIT_DB"; then + ok "${DB_NAME}: przywrócona." + RESTORED=$((RESTORED + 1)) + else + warn "${DB_NAME}: przywracanie zakończone z błędem lub ostrzeżeniami." + ERRORS=$((ERRORS + 1)) + fi + + echo "" +done + +# --------------------------------------------------------------------------- +# Podsumowanie +# --------------------------------------------------------------------------- +sekcja "Podsumowanie" +info "Przywrócono: ${RESTORED} | Błędy: ${ERRORS}" +echo "" + +[[ "$ERRORS" -eq 0 ]] || exit 1 +exit 0 diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..d7e5cdc --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CPIF="/usr/local/directadmin/data/admin/custom_package_items.conf" +SUDOERS_FILE="/etc/sudoers.d/directadmin-postgresql-plugin" + +if [ "$(id -u)" -eq 0 ]; then + if [ -f "$CPIF" ]; then + sed -i '/^postgresql_enabled=/d' "$CPIF" || true + sed -i '/^postgresql=/d' "$CPIF" || true + sed -i '/^upostgresql=/d' "$CPIF" || true + sed -i '/^postgresql_max_databases=/d' "$CPIF" || true + sed -i '/^postgresql_unlimited=/d' "$CPIF" || true + sed -i '/^upostgresql_max_databases=/d' "$CPIF" || true + chown diradmin:diradmin "$CPIF" 2>/dev/null || true + chmod 640 "$CPIF" 2>/dev/null || true + fi + + rm -f "$SUDOERS_FILE" || true +else + echo "postgresql: warning: uruchom jako root, aby usunąć wpisy z custom_package_items i sudoers" >&2 +fi + +if [ -f "$PLUGIN_DIR/plugin.conf" ]; then + sed -i 's/^active=.*/active=no/' "$PLUGIN_DIR/plugin.conf" || true + sed -i 's/^installed=.*/installed=no/' "$PLUGIN_DIR/plugin.conf" || true +fi + +echo "da-postgresql: deaktywowany." diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..430dd6e --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +"$(cd "$(dirname "$0")" && pwd)/install.sh" diff --git a/scripts/worker.sh b/scripts/worker.sh new file mode 100644 index 0000000..1511d7e --- /dev/null +++ b/scripts/worker.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DATA_DIR="$PLUGIN_DIR/data" +JOB_DIR="$DATA_DIR/jobs/pending" +PROCESSING_DIR="$DATA_DIR/jobs/processing" +DONE_DIR="$DATA_DIR/jobs/done" +WORKER_LOCK_DIR="$DATA_DIR/worker.lock" +DB_LOCK_DIR="$DATA_DIR/lock" +PID_DIR="$DATA_DIR/pids" +CANCEL_DIR="$DATA_DIR/cancel" + +mkdir -p "$JOB_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$PID_DIR" "$CANCEL_DIR" "$DB_LOCK_DIR" + +if ! mkdir "$WORKER_LOCK_DIR" 2>/dev/null; then + exit 0 +fi + +cleanup() { + rmdir "$WORKER_LOCK_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +db_lock_path_for_db() { + local db_name="$1" + local safe + safe="$(printf '%s' "$db_name" | tr -c 'A-Za-z0-9_.-' '_')" + printf '%s/%s.lock' "$DB_LOCK_DIR" "$safe" +} + +release_db_lock_later() { + local lock_path="$1" + if [ -z "$lock_path" ]; then + return 0 + fi + ( + sleep 60 + rm -f "$lock_path" 2>/dev/null || true + ) >/dev/null 2>&1 & +} + +while true; do + JOB_FILE="$(ls -1 "$JOB_DIR"/*.env 2>/dev/null | head -n 1 || true)" + if [ -z "${JOB_FILE:-}" ]; then + break + fi + + BASE_NAME="$(basename "$JOB_FILE")" + RUN_FILE="$PROCESSING_DIR/$BASE_NAME" + mv "$JOB_FILE" "$RUN_FILE" + JOB_ID="${BASE_NAME%.env}" + PID_FILE="$PID_DIR/${JOB_ID}.pid" + + JOB_TYPE="restore" + DB_NAME="" + DB_LOCK_FILE="" + # shellcheck disable=SC1090 + source "$RUN_FILE" || true + JOB_TYPE="${JOB_TYPE:-restore}" + DB_NAME="${DB_NAME:-}" + DB_LOCK_FILE="${DB_LOCK_FILE:-}" + + LOCK_PATH="" + if [ -n "$DB_LOCK_FILE" ]; then + case "$DB_LOCK_FILE" in + "$DB_LOCK_DIR"/*.lock) LOCK_PATH="$DB_LOCK_FILE" ;; + *) LOCK_PATH="" ;; + esac + fi + if [ -z "$LOCK_PATH" ] && [ -n "$DB_NAME" ]; then + LOCK_PATH="$(db_lock_path_for_db "$DB_NAME")" + fi + + RUNNER="$PLUGIN_DIR/scripts/restore_job.sh" + if [ "$JOB_TYPE" = "backup" ]; then + RUNNER="$PLUGIN_DIR/scripts/backup_job.sh" + fi + + set +e + "$RUNNER" "$RUN_FILE" & + RUN_PID=$! + echo "PID=${RUN_PID}" >> "$RUN_FILE" + echo "${RUN_PID}" > "$PID_FILE" + wait "$RUN_PID" + EXIT_CODE=$? + set -e + + rm -f "$PID_FILE" + + CANCEL_FLAG="${CANCEL_DIR}/${JOB_ID}.flag" + if [ -f "$CANCEL_FLAG" ]; then + rm -f "$CANCEL_FLAG" + mv "$RUN_FILE" "$DONE_DIR/${JOB_ID}.cancel" + elif [ "$EXIT_CODE" -eq 0 ]; then + mv "$RUN_FILE" "$DONE_DIR/${JOB_ID}.ok" + else + mv "$RUN_FILE" "$DONE_DIR/${JOB_ID}.fail" + fi + + release_db_lock_later "$LOCK_PATH" +done + +exit 0 diff --git a/user/assign_database.html b/user/assign_database.html new file mode 100755 index 0000000..ef6121a --- /dev/null +++ b/user/assign_database.html @@ -0,0 +1,6 @@ +#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/da-postgresql/php.ini +