diff --git a/app/Console/Commands/RestoreFromBackup.php b/app/Console/Commands/RestoreFromBackup.php new file mode 100644 index 0000000000..5d9042a046 --- /dev/null +++ b/app/Console/Commands/RestoreFromBackup.php @@ -0,0 +1,249 @@ +argument('filename'); + + if (!$filename) { + return $this->error("Missing required filename"); + } + + if (!$this->option('force') && !$this->confirm('Are you sure you wish to restore from the given backup file? This can lead to MASSIVE DATA LOSS!')) { + return $this->error("Data loss not confirmed"); + } + + if (config('database.default') != 'mysql') { + return $this->error("DB_CONNECTION must be MySQL in order to perform a restore. Detected: ".config('database.default')); + } + + $za = new ZipArchive(); + + $errcode = $za->open($filename, ZipArchive::RDONLY); + if ($errcode !== true) { + $errors = [ + ZipArchive::ER_EXISTS => "File already exists.", + ZipArchive::ER_INCONS => "Zip archive inconsistent.", + ZipArchive::ER_INVAL => "Invalid argument.", + ZipArchive::ER_MEMORY => "Malloc failure.", + ZipArchive::ER_NOENT => "No such file.", + ZipArchive::ER_NOZIP => "Not a zip archive.", + ZipArchive::ER_OPEN => "Can't open file.", + ZipArchive::ER_READ => "Read error.", + ZipArchive::ER_SEEK => "Seek error." + ]; + + return $this->error("Could not access file: ".$filename." - ".array_key_exists($errcode,$errors) ? $errors[$errcode] : " Unknown reason: $errcode"); + } + + + $private_dirs = [ + 'storage/private_uploads/assets', + 'storage/private_uploads/audits', + 'storage/private_uploads/imports', + 'storage/private_uploads/assetmodels', + 'storage/private_uploads/users', + 'storage/private_uploads/licenses', + 'storage/private_uploads/signatures' // We probably don't want this in the first place. + ]; + $private_files = [ + 'storage/oauth-private.key', + 'storage/oauth-public.key' + ]; + $public_dirs = [ + 'public/uploads/companies', + 'public/uploads/components', + 'public/uploads/categories', + 'public/uploads/manufacturers', + 'public/uploads/barcodes', // don't want this neither + 'public/uploads/consumables', + 'public/uploads/departments', + 'public/uploads/avatars', + 'public/uploads/suppliers', + 'public/uploads/assets', //wait, what?! + 'public/uploads/locations', + 'public/uploads/accessories', + 'public/uploads/models', + ]; + + $public_files = [ + 'public/uploads/logo.png' + ]; + + $all_files = $private_dirs + $public_dirs; + + $sqlfiles = []; + $sqlfile_indices = []; + + $interesting_files = []; + + for ($i=0; $i<$za->numFiles;$i++) { + $stat_results = $za->statIndex($i); + // echo "index: $i\n"; + // print_r($stat_results); + + $raw_path = $stat_results['name']; + // skip macOS resource fork files (?!?!?!) + if(strpos($raw_path,"__MACOSX")!==false && strpos($raw_path,"._") !== false) { + //print "SKIPPING macOS Resource fork file: $raw_path\n"; + continue; + } + if(strpos($raw_path,'\\')!==false) { //found a backslash, swap it to forward-slash + $raw_path = strtr($raw_path,'\\','/'); + //print "Translating file: ".$stat_results['name']." to: ".$raw_path."\n"; + } + + if(@pathinfo($raw_path)['extension'] == "sql") { + print "Found a sql file!\n"; + $sqlfiles[] = $raw_path; + $sqlfile_indices[] = $i; + } + + foreach($private_dirs+$public_dirs as $dir) { + $last_pos = strrpos($raw_path,$dir.'/'); + if($last_pos !== false ) { + //print("INTERESTING - last_pos is $last_pos when searching $raw_path for $dir - last_pos+strlen(\$dir) is: ".($last_pos+strlen($dir))." and strlen(\$rawpath) is: ".strlen($raw_path)."\n"); + print("We would copy $raw_path to $dir.\n"); //FIXME append to a path? + $interesting_files[$raw_path] = ['dest' =>$dir, 'index' => $i]; + if($last_pos + strlen($dir) +1 == strlen($raw_path)) { + // we don't care about that; we just want files with the appropriate prefix + //print("FOUND THE EXACT DIRECTORY: $dir AT: $raw_path!!!\n"); + } + } + } + foreach($private_files+$public_files as $file) { + $last_pos = strrpos($raw_path,$file); // no trailing slash! + if($last_pos !== false ) { + //print("INTERESTING - last_pos is $last_pos when searching $raw_path for $file - last_pos+strlen(\$file) is: ".($last_pos+strlen($file))." and strlen(\$rawpath) is: ".strlen($raw_path)."\n"); + if($last_pos + strlen($file) == strlen($raw_path)) { //again, no trailing slash + print("FOUND THE EXACT FILE: $file AT: $raw_path!!!\n"); //we *do* care about this, though. + $interesting_files[$raw_path] = ['dest' => dirname($file),'index' => $i]; + } + } + + } + } + + // print_r($interesting_files);exit(-1); + + if( count($sqlfiles) != 1) { + return $this->error("There should be exactly *one* sql backup file found, found: ".( count($sqlfiles) == 0 ? "None" : implode(", ",$sqlfiles))); + } + + if( strpos($sqlfiles[0], "db-dumps") === false ) { + //return $this->error("SQL backup file is missing 'db-dumps' component of full pathname: ".$sqlfiles[0]); + //older Snipe-IT installs don't have the db-dumps subdirectory component + } + + //how to invoke the restore? + $pipes = []; + + // FIXME - absolutely can *NOT* be hardcoding paths like this!!!!!!! But I don't know how to do it right? (Maybe get the user's ENV and append the MYSQL_PWD to it?) + $env_vars = getenv(); + $env_vars['MYSQL_PWD'] = config("database.connections.mysql.password"); + $proc_results = proc_open("mysql -h ".escapeshellarg(config('database.connections.mysql.host'))." -u ".escapeshellarg(config('database.connections.mysql.username'))." ".escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV + [0 => ['pipe','r'],1 => ['pipe','w'],2 => ['pipe','w']], + $pipes, + null, + $env_vars); // this is not super-duper awesome-secure, but definitely more secure than showing it on the CLI, or dropping temporary files with passwords in them. + if($proc_results === false) { + return $this->error("Unable to invoke mysql via CLI"); + } + + // $this->info("Stdout says? ".fgets($pipes[1])); //FIXME: I think we might need to set non-blocking mode to use this properly? + // $this->info("Stderr says? ".fgets($pipes[2])); //FIXME: ditto, same. + // should we read stdout? + // fwrite($pipes[0],config("database.connections.mysql.password")."\n"); //this doesn't work :( + + //$sql_contents = fopen($sqlfiles[0], "r"); //NOPE! This isn't a real file yet, silly-billy! + + $sql_stat = $za->statIndex($sqlfile_indices[0]); + $this->info("SQL Stat is: ".print_r($sql_stat,true)); + $sql_contents = $za->getStream($sql_stat['name']); + if ($sql_contents === false) { + $stdout = fgets($pipes[1]); + $this->info($stdout); + $stderr = fgets($pipes[2]); + $this->info($stderr); + return false; + } + + while(($buffer = fgets($sql_contents)) !== false ) { + //$this->info("Buffer is: '$buffer'"); + $bytes_written = fwrite($pipes[0],$buffer); + if($bytes_written === false) { + $stdout = fgets($pipes[1]); + $this->info($stdout); + $stderr = fgets($pipes[2]); + $this->info($stderr); + return false; + } + } + fclose($pipes[0]); + fclose($sql_contents); + fclose($pipes[1]); + fclose($pipes[2]); + //wait, have to do fclose() on all pipes first? + $close_results = proc_close($proc_results); + if($close_results != 0) { + return $this->error("There may have been a problem with the database import: Error number ".$close_results); + } + + //and now copy the files over too (right?) + //FIXME - we don't prune the filesystem space yet!!!! + foreach($interesting_files AS $pretty_file_name => $file_details) { + $ugly_file_name = $za->statIndex($file_details['index'])['name']; + $fp = $za->getStream($ugly_file_name); + $this->info("Weird problem, here are file details? ".print_r($file_details,true)); + $migrated_file = fopen($file_details['dest']."/".basename($pretty_file_name),"w"); + while(($buffer = fgets($fp))!== false) { + fwrite($migrated_file,$buffer); + } + fclose($migrated_file); + fclose($fp); + $this->info("Wrote $ugly_file_name to $pretty_file_name"); + } + + } +}