Merge pull request #10297 from snipe/features/backup_importer_ui

v6 Feature - Added backup restore from GUI
This commit is contained in:
snipe 2021-11-15 19:09:37 -08:00 committed by GitHub
commit ce69e54202
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 352 additions and 50 deletions

View file

@ -14,7 +14,7 @@ class RestoreFromBackup extends Command
*/
protected $signature = 'snipeit:restore
{--force : Skip the danger prompt; assuming you hit "y"}
{filename : The zip file to be migrated}
{filename : The full path of the .zip file to be migrated}
{--no-progress : Don\'t show a progress bar}';
/**
@ -22,7 +22,7 @@ class RestoreFromBackup extends Command
*
* @var string
*/
protected $description = 'Restore from a previously created backup';
protected $description = 'Restore from a previously created Snipe-IT backup file';
/**
* Create a new command instance.
@ -34,6 +34,8 @@ class RestoreFromBackup extends Command
parent::__construct();
}
public static $buffer_size = 1024 * 1024; // use a 1MB buffer, ought to work fine for most cases?
/**
* Execute the console command.
*
@ -42,7 +44,10 @@ class RestoreFromBackup extends Command
public function handle()
{
$dir = getcwd();
echo "Current working directory is: $dir\n";
if( $dir != base_path() ) { // usually only the case when running via webserver, not via command-line
\Log::debug("Current working directory is: $dir, changing directory to: ".base_path());
chdir(base_path()); // TODO - is this *safe* to change on a running script?!
}
//
$filename = $this->argument('filename');
@ -67,7 +72,7 @@ class RestoreFromBackup extends Command
ZipArchive::ER_INCONS => 'Zip archive inconsistent.',
ZipArchive::ER_INVAL => 'Invalid argument.',
ZipArchive::ER_MEMORY => 'Malloc failure.',
ZipArchive::ER_NOENT => 'No such file.',
ZipArchive::ER_NOENT => 'No such file ('.$filename.') in directory '.$dir.'.',
ZipArchive::ER_NOZIP => 'Not a zip archive.',
ZipArchive::ER_OPEN => "Can't open file.",
ZipArchive::ER_READ => 'Read error.',
@ -144,7 +149,7 @@ class RestoreFromBackup extends Command
continue;
}
if (@pathinfo($raw_path)['extension'] == 'sql') {
echo "Found a sql file!\n";
\Log::debug("Found a sql file!");
$sqlfiles[] = $raw_path;
$sqlfile_indices[] = $i;
continue;
@ -206,7 +211,13 @@ class RestoreFromBackup extends Command
$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
// TODO notes: we are stealing the dump_binary_path (which *probably* also has your copy of the mysql binary in it. But it might not, so we might need to extend this)
// we unilaterally prepend a slash to the `mysql` command. This might mean your path could look like /blah/blah/blah//mysql - which should be fine. But maybe in some environments it isn't?
$mysql_binary = config('database.connections.mysql.dump.dump_binary_path').'/mysql';
if( ! file_exists($mysql_binary) ) {
return $this->error("mysql tool at: '$mysql_binary' does not exist, cannot restore. Please edit DB_DUMP_PATH in your .env to point to a directory that contains the mysqldump and mysql binary");
}
$proc_results = proc_open("$mysql_binary -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,
@ -233,9 +244,10 @@ class RestoreFromBackup extends Command
return false;
}
while (($buffer = fgets($sql_contents)) !== false) {
//$this->info("Buffer is: '$buffer'");
$bytes_read = 0;
while (($buffer = fgets($sql_contents, self::$buffer_size)) !== false) {
$bytes_read += strlen($buffer);
// \Log::debug("Buffer is: '$buffer'");
$bytes_written = fwrite($pipes[0], $buffer);
if ($bytes_written === false) {
$stdout = fgets($pipes[1]);
@ -246,6 +258,10 @@ class RestoreFromBackup extends Command
return false;
}
}
if (!feof($sql_contents) || $bytes_read == 0) {
return $this->error("Not at end of file for sql file, or zero bytes read. aborting!");
}
fclose($pipes[0]);
fclose($sql_contents);
@ -273,7 +289,7 @@ class RestoreFromBackup extends Command
$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) {
while (($buffer = fgets($fp, self::$buffer_size)) !== false) {
fwrite($migrated_file, $buffer);
}
fclose($migrated_file);

View file

@ -899,7 +899,7 @@ class AssetsController extends Controller
}
}
return response()->json(Helper::formatStandardApiResponse('error', ['asset_tag'=> e($request->input('asset_tag'))], 'Asset with tag '.$request->input('asset_tag').' not found'));
return response()->json(Helper::formatStandardApiResponse('error', ['asset_tag'=> e($request->input('asset_tag'))], 'Asset with tag '.e($request->input('asset_tag')).' not found'));

View file

@ -11,7 +11,6 @@ use App\Models\Setting;
use App\Models\User;
use App\Notifications\FirstAdminNotification;
use App\Notifications\MailTest;
use Artisan;
use Auth;
use Crypt;
use DB;
@ -22,6 +21,8 @@ use Image;
use Input;
use Redirect;
use Response;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Artisan;
/**
* This controller handles all actions related to Settings for
@ -1018,17 +1019,25 @@ class SettingsController extends Controller
$backup_files = Storage::files($path);
$files_raw = [];
if (count($backup_files) > 0) {
for ($f = 0; $f < count($backup_files); $f++) {
// Skip dotfiles like .gitignore and .DS_STORE
if ((substr(basename($backup_files[$f]), 0, 1) != '.')) {
//$lastmodified = Carbon::parse(Storage::lastModified($backup_files[$f]))->toDatetimeString();
$file_timestamp = Storage::lastModified($backup_files[$f]);
$files_raw[] = [
'filename' => basename($backup_files[$f]),
'filesize' => Setting::fileSizeConvert(Storage::size($backup_files[$f])),
'modified' => Storage::lastModified($backup_files[$f]),
'modified_value' => $file_timestamp,
'modified_display' => Helper::getFormattedDateObject($file_timestamp, $type = 'datetime', false),
];
}
}
}
@ -1128,6 +1137,111 @@ class SettingsController extends Controller
}
}
/**
* Uploads a backup file
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v6.0]
*
* @return Redirect
*/
public function postUploadBackup(Request $request) {
if (!$request->hasFile('file')) {
return redirect()->route('settings.backups.index')->with('error', 'No file uploaded');
} else {
$max_file_size = Helper::file_upload_max_size();
$rules = [
'file' => 'required|mimes:zip|max:'.$max_file_size,
];
$validator = \Validator::make($request->all(), $rules);
if ($validator->passes()) {
$upload_filename = 'uploaded-'.date('U').'-'.Str::slug(pathinfo($request->file('file')->getClientOriginalName(), PATHINFO_FILENAME)).'.zip';
Storage::putFileAs('app/backups', $request->file('file'), $upload_filename);
return redirect()->route('settings.backups.index')->with('success', 'File uploaded');
} else {
return redirect()->route('settings.backups.index')->withErrors($request->getErrors());
}
}
}
/**
* Restore the backup file.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v6.0]
*
* @return View
*/
public function postRestore($filename = null)
{
if (! config('app.lock_passwords')) {
$path = 'app/backups';
if (Storage::exists($path.'/'.$filename)) {
// grab the user's info so we can make sure they exist in the system
$user = User::find(Auth::user()->id);
// TODO: run a backup
// TODO: add db:wipe
// run the restore command
Artisan::call('snipeit:restore',
[
'--force' => true,
'--no-progress' => true,
'filename' => storage_path($path).'/'.$filename
]);
$output = Artisan::output();
// If it's greater than 300, it probably worked
if (strlen($output) > 300) {
return redirect()->route('settings.backups.index')->with('success', 'Your system has been restored.');
} else {
return redirect()->route('settings.backups.index')->with('error', $output);
}
//dd($output);
// TODO: insert the user if they are not there in the old one
// log the user out
// \Auth::logout();
} else {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
} else {
return redirect()->route('settings.backups.index')->with('error', trans('general.feature_disabled'));
}
}
/**
* Return a form to allow a super admin to update settings.
*

View file

@ -21,7 +21,7 @@ class AssetFileRequest extends Request
*/
public function rules()
{
$max_file_size = Helper::file_upload_max_size();
$max_file_size = \App\Helpers\Helper::file_upload_max_size();
return [
'file.*' => 'required|mimes:png,gif,jpg,svg,jpeg,doc,docx,pdf,txt,zip,rar,xls,xlsx,lic,xml,rtf,webp|max:'.$max_file_size,

View file

@ -90,11 +90,6 @@ class ImageUploadRequest extends Request
$use_db_field = $db_fieldname;
}
\Log::info('Image path is: '.$path);
\Log::debug('Type is: '.$type);
\Log::debug('Form fieldname is: '.$form_fieldname);
\Log::debug('DB fieldname is: '.$use_db_field);
\Log::debug('Trying to upload to '. $path);
// ConvertBase64ToFiles just changes object type,
// as it cannot currently insert files to $this->files

2
package-lock.json generated
View file

@ -15845,7 +15845,7 @@
"jquery": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz",
"integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw=="
"integrity": "sha1-xyoJ8Vwb3OFC9J2/EXC9+K2sJHA="
},
"jquery-form-validator": {
"version": "2.3.79",

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/dist/all.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -1,5 +1,5 @@
{
"/js/build/app.js": "/js/build/app.js?id=c8a70594c0d99275266d",
"/js/build/app.js": "/js/build/app.js?id=202aad1c623e7f3c560c",
"/css/dist/skins/skin-blue.css": "/css/dist/skins/skin-blue.css?id=83e39e254b7f9035eddc",
"/css/build/overrides.css": "/css/build/overrides.css?id=b1866ec98d44c0a8ceea",
"/css/build/app.css": "/css/build/app.css?id=61d5535cb27cce41d422",
@ -26,7 +26,7 @@
"/css/dist/bootstrap-table.css": "/css/dist/bootstrap-table.css?id=93c24b4c89490bbfd73e",
"/js/build/vendor.js": "/js/build/vendor.js?id=651427cc4b45d8e68d0c",
"/js/dist/bootstrap-table.js": "/js/dist/bootstrap-table.js?id=867755a1544f6c0ea828",
"/js/dist/all.js": "/js/dist/all.js?id=a233dcde4650f5d34491",
"/js/dist/all.js": "/js/dist/all.js?id=78b490e0623f67ef071d",
"/css/dist/skins/skin-green.min.css": "/css/dist/skins/skin-green.min.css?id=efda2335fa5243175850",
"/css/dist/skins/skin-green-dark.min.css": "/css/dist/skins/skin-green-dark.min.css?id=6e35fb4cb2f1063b3047",
"/css/dist/skins/skin-black.min.css": "/css/dist/skins/skin-black.min.css?id=ec96c42439cdeb022133",

View file

@ -84,6 +84,37 @@ var baseUrl = $('meta[name="baseUrl"]').attr('content');
var Components = {};
Components.modals = {};
// confirm restore modal
Components.modals.confirmRestore = function() {
var $el = $('table');
var events = {
'click': function(evnt) {
var $context = $(this);
var $restoreConfirmModal = $('#restoreConfirmModal');
var href = $context.attr('href');
var message = $context.attr('data-content');
var title = $context.attr('data-title');
$('#restoreConfirmModalLabel').text(title);
$restoreConfirmModal.find('.modal-body').text(message);
$('#restoreForm').attr('action', href);
$restoreConfirmModal.modal({
show: true
});
return false;
}
};
var render = function() {
$el.on('click', '.restore-asset', events['click']);
};
return {
render: render
};
};
// confirm delete modal
Components.modals.confirmDelete = function() {
var $el = $('table');
@ -121,6 +152,7 @@ var baseUrl = $('meta[name="baseUrl"]').attr('content');
* Component definition stays out of load event, execution only happens.
*/
$(function() {
new Components.modals.confirmRestore().render();
new Components.modals.confirmDelete().render();
});
}(jQuery, window.snipeit.settings));

View file

@ -113,6 +113,8 @@
'image' => 'Image',
'image_delete' => 'Delete Image',
'image_upload' => 'Upload Image',
'filetypes_accepted_help' => 'Accepted filetype is :types. Max upload size allowed is :size.|Accepted filetypes are :types. Max upload size allowed is :size.',
'filetypes_size_help' => 'Max upload size allowed is :size.',
'image_filetypes_help' => 'Accepted filetypes are jpg, webp, png, gif, and svg. Max upload size allowed is :size.',
'import' => 'Import',
'importing' => 'Importing',

View file

@ -852,6 +852,28 @@
</div>
</div>
<div class="modal modal-warning fade" id="restoreConfirmModal" tabindex="-1" role="dialog" aria-labelledby="confirmModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title" id="confirmModalLabel">&nbsp;</h4>
</div>
<div class="modal-body"></div>
<div class="modal-footer">
<form method="post" id="restoreForm" role="form">
{{ csrf_field() }}
{{ method_field('POST') }}
<button type="button" class="btn btn-default pull-left" data-dismiss="modal">{{ trans('general.cancel') }}</button>
<button type="submit" class="btn btn-outline" id="dataConfirmOK">{{ trans('general.yes') }}</button>
</form>
</div>
</div>
</div>
</div>
{{-- Javascript files --}}
<script src="{{ url(mix('js/dist/all.js')) }}" nonce="{{ csrf_token() }}"></script>

View file

@ -47,7 +47,7 @@
<i class="far fa-list-alt fa-2x" aria-hidden="true"></i>
</span>
<span class="hidden-xs hidden-sm">{{ trans('admin/licenses/form.seats') }}</span>
<badge class="badge badge-secondary">{{ $license->availCount()->count() }} / {{ $license->seats }}</badge>
<span class="badge badge-secondary">{{ $license->availCount()->count() }} / {{ $license->seats }}</span>
</a>
</li>

View file

@ -7,7 +7,15 @@
@stop
@section('header_right')
<a href="{{ route('settings.index') }}" class="btn btn-primary"> {{ trans('general.back') }}</a>
<a href="{{ route('settings.index') }}" class="btn btn-default pull-right" style="margin-left: 5px;">
{{ trans('general.back') }}
</a>
<form method="POST" style="display: inline">
{{ Form::hidden('_token', csrf_token()) }}
<button class="btn btn-primary {{ (config('app.lock_passwords')) ? ' disabled': '' }}">{{ trans('admin/settings/general.generate_backup') }}</button>
</form>
@stop
{{-- Page content --}}
@ -15,10 +23,16 @@
<div class="row">
<div class="col-md-9">
<div class="col-md-8">
<div class="box box-default">
<div class="box-body">
<div class="table-responsive">
<table
data-cookie="true"
data-cookie-id-table="system-backups"
@ -30,10 +44,13 @@
id="system-backups"
class="table table-striped snipe-table">
<thead>
<th>File</th>
<th>Created</th>
<th>Size</th>
<tr>
<th data-sortable="true">File</th>
<th data-sortable="true" data-field="modified_display" data-sort-name="modified_value">Created</th>
<th data-field="modified_value" data-visible="false"></th>
<th data-sortable="true">Size</th>
<th><span class="sr-only">{{ trans('general.delete') }}</span></th>
</tr>
</thead>
<tbody>
@foreach ($files as $file)
@ -43,52 +60,148 @@
{{ $file['filename'] }}
</a>
</td>
<td>{{ date("M d, Y g:i A", $file['modified']) }} </td>
<td>{{ $file['modified_display'] }} </td>
<td>{{ $file['modified_value'] }} </td>
<td>{{ $file['filesize'] }}</td>
<td>
@can('superadmin')
<a data-html="false"
class="btn delete-asset btn-danger btn-sm {{ (config('app.lock_passwords')) ? ' disabled': '' }}" data-toggle="modal" href=" {{ route('settings.backups.destroy', $file['filename']) }}" data-content="{{ trans('admin/settings/message.backup.delete_confirm') }}" data-title="{{ trans('general.delete') }} {{ htmlspecialchars($file['filename']) }} ?" onClick="return false;">
class="btn delete-asset btn-danger btn-sm {{ (config('app.lock_passwords')) ? ' disabled': '' }}" data-toggle="modal" href="{{ route('settings.backups.destroy', $file['filename']) }}" data-content="{{ trans('admin/settings/message.backup.delete_confirm') }}" data-title="{{ trans('general.delete') }} {{ e($file['filename']) }} ?" onClick="return false;">
<i class="fas fa-trash icon-white" aria-hidden="true"></i>
<span class="sr-only">{{ trans('general.delete') }}</span>
</a>
<a data-html="true" href="{{ route('settings.backups.restore', $file['filename']) }}" class="btn btn-warning btn-sm restore-asset {{ (config('app.lock_passwords')) ? ' disabled': '' }}" data-toggle="modal" data-content="Yes, restore it. I acknowledge that this will overwrite any existing data currently in the database. This will also log out all of your existing users (including you)." data-title="Are you sure you wish to restore your database from {{ e($file['filename']) }}?" onClick="return false;">
<i class="fas fa-retweet" aria-hidden="true"></i>
<span class="sr-only">Restore</span>
</a>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- side address column -->
<div class="col-md-3">
</div> <!-- end table-responsive div -->
</div> <!-- end box-body div -->
</div> <!-- end box div -->
</div> <!-- end col-md div -->
<form method="POST">
{{ Form::hidden('_token', csrf_token()) }}
<!-- side address column -->
<div class="col-md-4">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title">
<i class="far fa-file-archive" aria-hidden="true"></i>
Upload Backup</h2>
<div class="box-tools pull-right">
</div>
</div><!-- /.box-header -->
<div class="box-body">
<p>Backup files on the server are stored in: <code>{{ $path }}</code></p>
{{ Form::open([
'method' => 'POST',
'route' => 'settings.backups.upload',
'files' => true,
'class' => 'form-horizontal' ]) }}
@csrf
<div class="form-group {{ $errors->has((isset($fieldname) ? $fieldname : 'image')) ? 'has-error' : '' }}" style="margin-bottom: 0px;">
<div class="col-md-8 col-xs-8">
<!-- screen reader only -->
<input type="file" id="file" name="file" aria-label="file" class="sr-only">
<!-- displayed on screen -->
<label class="btn btn-default col-md-12 col-xs-12" aria-hidden="true">
<i class="fas fa-paperclip" aria-hidden="true"></i>
{{ trans('button.select_file') }}
<input type="file" name="file" class="js-uploadFile" id="uploadFile" data-maxsize="{{ Helper::file_upload_max_size() }}" accept="application/zip" style="display:none;" aria-label="file" aria-hidden="true">
</label>
</div>
<div class="col-md-4 col-xs-4">
<button class="btn btn-primary col-md-12 col-xs-12" id="uploadButton" disabled>Upload</button>
</div>
<div class="col-md-12">
<p class="label label-default col-md-12" style="font-size: 120%!important; margin-top: 10px; margin-bottom: 10px;" id="uploadFile-info"></p>
<p class="help-block" style="margin-top: 10px;" id="uploadFile-status">{{ trans_choice('general.filetypes_accepted_help', 1, ['size' => Helper::file_upload_max_size_readable(), 'types' => '.zip']) }}</p>
{!! $errors->first('image', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
</div>
</div>
{{ Form::close() }}
</div>
</div>
<div class="box box-warning">
<div class="box-header with-border">
<h2 class="box-title">
<i class="fas fa-exclamation-triangle text-orange" aria-hidden="true"></i> Restoring from Backup</h2>
<div class="box-tools pull-right">
</div>
</div><!-- /.box-header -->
<div class="box-body">
<p>
<button class="btn btn-primary {{ (config('app.lock_passwords')) ? ' disabled': '' }}">{{ trans('admin/settings/general.generate_backup') }}</button>
Use the restore button <small><span class="btn btn-xs btn-warning"><i class="text-white fas fa-retweet" aria-hidden="true"></i></span></small> to
restore from a previous backup. (This does not currently with with S3 file storage.)</p>
<p>Your <strong>entire {{ config('app.name') }} database and any uploaded files will be completely replaced</strong> by what's in the backup file.
</p>
@if (config('app.lock_passwords'))
<p class="text-warning"><i class="fas fa-lock"></i> {{ trans('general.feature_disabled') }}</p>
@endif
</form>
<p>Backup files are located in: {{ $path }}</p>
<p class="text-danger" style="font-weight: bold; font-size: 120%;">
You will be logged out once your restore is complete.
</p>
<p>
Very large backups may time out on the restore attempt and may still need to be run via command line.
</p>
</div>
</div>
</div> <!-- end col-md-12 form div -->
</div> <!-- end form group div -->
</div> <!-- end col-md-3 div -->
</div> <!-- end row div -->
@stop
@section('moar_scripts')
@include ('partials.bootstrap-table')
<script>
/*
* This just disables the upload button via JS unless they have actually selected a file.
*
* Todo: - key off the javascript response for JS file upload info as well, so that if it fails that
* check (file size and type) we should also leave it disabled.
*/
$(document).ready(function() {
$("#uploadFile").on('change',function(event){
if ($('#uploadFile').val().length == 0) {
$("#uploadButton").attr("disabled", true);
} else {
$('#uploadButton').removeAttr('disabled');
}
});
});
</script>
@stop

View file

@ -189,6 +189,14 @@ Route::group(['prefix' => 'admin', 'middleware' => ['auth', 'authorize:superuser
[SettingsController::class, 'postBackups']
)->name('settings.backups.create');
Route::post('/restore/{filename}',
[SettingsController::class, 'postRestore']
)->name('settings.backups.restore');
Route::post('/upload',
[SettingsController::class, 'postUploadBackup']
)->name('settings.backups.upload');
Route::get('/', [SettingsController::class, 'getBackups'])->name('settings.backups.index');
});