From b356f8e5874db1d03d45858416fa18375d792548 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sun, 15 Mar 2015 14:46:25 +0200 Subject: [PATCH] implemented configuration backup and restore --- src/config.py | 78 +++++++++++++++++++++++++++ src/handlers.py | 31 +++++++++++ src/server.py | 2 +- src/utils.py | 3 ++ static/css/main.css | 18 ++++--- static/css/ui.css | 3 +- static/js/main.js | 129 ++++++++++++++++++++++++++++++++++++++++++-- static/js/ui.js | 53 ++++++++++++++++++ templates/main.html | 21 ++++++-- 9 files changed, 320 insertions(+), 18 deletions(-) diff --git a/src/config.py b/src/config.py index 35420e3..93b4aa9 100644 --- a/src/config.py +++ b/src/config.py @@ -15,14 +15,19 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import datetime import errno import logging import os.path import re import shlex +import subprocess + +from tornado.ioloop import IOLoop import diskctl import motionctl +import powerctl import settings import smbctl import update @@ -1144,6 +1149,79 @@ def camera_dict_to_ui(data): return ui +def backup(): + logging.debug('generating config backup file') + + if len(os.listdir(settings.CONF_PATH)) > 100: + logging.debug('config path "%s" appears to be a system-wide config directory, performing a selective backup' % settings.CONF_PATH) + cmd = 'cd "%s" && tar zc motion.conf thread-*.conf' % settings.CONF_PATH + try: + content = subprocess.check_output(cmd, shell=True) + logging.debug('backup file created (%s bytes)' % len(content)) + + return content + + except Exception as e: + logging.error('backup failed: %s' % e, exc_info=True) + + return None + + else: + logging.debug('config path "%s" appears to be a motion-specific config directory, performing a full backup' % settings.CONF_PATH) + + cmd = 'cd "%s" && tar zc .' % settings.CONF_PATH + try: + content = subprocess.check_output(cmd, shell=True) + logging.debug('backup file created (%s bytes)' % len(content)) + + return content + + except Exception as e: + logging.error('backup failed: %s' % e, exc_info=True) + + return None + + +def restore(content): + global _main_config_cache + global _camera_config_cache + global _camera_ids_cache + global _additional_structure_cache + + logging.info('restoring config from backup file') + + cmd = 'tar zxC "%s" || true' % settings.CONF_PATH + + try: + p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + msg = p.communicate(content)[0] + if msg: + logging.error('failed to restore configuration: %s' % msg) + return False + + logging.debug('configuration restored successfully') + + if settings.ENABLE_REBOOT: + def later(): + powerctl.reboot() + + IOLoop.instance().add_timeout(datetime.timedelta(seconds=2), later) + + else: + logging.info('invalidating config cache') + _main_config_cache = None + _camera_config_cache = {} + _camera_ids_cache = None + _additional_structure_cache = {} + + return {'reboot': settings.ENABLE_REBOOT} + + except Exception as e: + logging.error('failed to restore configuration: %s' % e, exc_info=True) + + return None + + def _value_to_python(value): value_lower = value.lower() if value_lower == 'off': diff --git a/src/handlers.py b/src/handlers.py index d390fe3..dc0bf95 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -171,6 +171,9 @@ class ConfigHandler(BaseHandler): elif op == 'list_devices': self.list_devices() + + elif op == 'backup': + self.backup() else: raise HTTPError(400, 'unknown operation') @@ -191,6 +194,9 @@ class ConfigHandler(BaseHandler): elif op == 'rem': self.rem_camera(camera_id) + + elif op == 'restore': + self.restore() elif op == '_relay_event': self._relay_event(camera_id) @@ -645,6 +651,31 @@ class ConfigHandler(BaseHandler): motionctl.start() self.finish_json() + + @BaseHandler.auth(admin=True) + def backup(self): + content = config.backup() + + filename = 'motioneye-config.tar.gz' + self.set_header('Content-Type', 'application/x-compressed') + self.set_header('Content-Disposition', 'attachment; filename=' + filename + ';') + + self.finish(content) + + @BaseHandler.auth(admin=True) + def restore(self): + try: + content = self.request.files['files'][0]['body'] + + except KeyError: + raise HTTPError(400, 'file attachment required') + + result = config.restore(content) + if result: + self.finish_json({'ok': True, 'reboot': result['reboot']}) + + else: + self.finish_json({'ok': False}) @BaseHandler.auth(admin=True) def _relay_event(self, camera_id): diff --git a/src/server.py b/src/server.py index 19b3d4a..2e6bf10 100644 --- a/src/server.py +++ b/src/server.py @@ -43,7 +43,7 @@ application = Application( (r'^/$', handlers.MainHandler), (r'^/config/main/(?Pset|get)/?$', handlers.ConfigHandler), (r'^/config/(?P\d+)/(?Pget|set|rem|set_preview|_relay_event)/?$', handlers.ConfigHandler), - (r'^/config/(?Padd|list|list_devices)/?$', handlers.ConfigHandler), + (r'^/config/(?Padd|list|list_devices|backup|restore)/?$', handlers.ConfigHandler), (r'^/picture/(?P\d+)/(?Pcurrent|list|frame)/?$', handlers.PictureHandler), (r'^/picture/(?P\d+)/(?Pdownload|preview|delete)/(?P.+?)/?$', handlers.PictureHandler), (r'^/picture/(?P\d+)/(?Pzipped|timelapse|delete_all)/(?P.+?)/?$', handlers.PictureHandler), diff --git a/src/utils.py b/src/utils.py index 1ae4d92..db32598 100644 --- a/src/utils.py +++ b/src/utils.py @@ -309,6 +309,9 @@ def compute_signature(method, uri, body, key): parts[3] = query uri = urlparse.urlunsplit(parts) + if body and body.startswith('---'): + body = None # file attachment + return hashlib.sha1('%s:%s:%s:%s' % (method, uri, body or '', key)).hexdigest().lower() diff --git a/static/css/main.css b/static/css/main.css index fd938d8..95c44bf 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -315,7 +315,7 @@ span.settings-item-unit { div.settings-item-separator { height: 1px; border-top: 1px solid #414141; - margin: 5px 0px; + margin: 0.5em 1em; } #cameraSelect { @@ -361,9 +361,7 @@ img.apply-progress { margin-top: 3px; } -div.update-button, -div.shut-down-button, -div.reboot-button { +div.normal-button { position: relative; height: 1.5em; line-height: 1.5em; @@ -376,7 +374,9 @@ div.reboot-button { width: 7em; } -div.update-button { +div.update-button, +div.backup-button, +div.restore-button { background: #317CAD; } @@ -385,7 +385,9 @@ div.reboot-button { background: #c0392b; } -div.update-button:HOVER { +div.update-button:HOVER, +div.backup-button:HOVER, +div.restore-button:HOVER { background: #3498db; } @@ -394,7 +396,9 @@ div.reboot-button:HOVER { background: #D43F2F; } -div.update-button:ACTIVE { +div.update-button:ACTIVE, +div.backup-button:ACTIVE, +div.restore-button:ACTIVE { background: #317CAD; } diff --git a/static/css/ui.css b/static/css/ui.css index 823f74b..15a33b3 100644 --- a/static/css/ui.css +++ b/static/css/ui.css @@ -165,7 +165,8 @@ input[type=text].number { } input[type=text].error, -input[type=password].error { +input[type=password].error, +input[type=file].error { background-image: url(../img/validation-error.svg); background-position: center right; background-repeat: no-repeat; diff --git a/static/js/main.js b/static/js/main.js index 4a9dbbc..16869a9 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -195,8 +195,13 @@ function ajax(method, url, data, callback, error) { url += '_=' + new Date().getTime(); var json = false; + var processData = true; if (method == 'POST') { - if (typeof data == 'object') { + if (window.FormData && (data instanceof FormData)) { + json = false; + processData = false; + } + else if (typeof data == 'object') { data = JSON.stringify(data); json = true; } @@ -208,7 +213,7 @@ function ajax(method, url, data, callback, error) { } } - url = addAuthParams(method, url, data); + url = addAuthParams(method, url, processData ? data : null); var options = { type: method, @@ -232,7 +237,8 @@ function ajax(method, url, data, callback, error) { } } }, - contentType: json ? 'application/json' : null, + contentType: json ? 'application/json' : false, + processData: processData, error: error || function (request, options, error) { showErrorMessage(); if (callback) { @@ -1651,6 +1657,19 @@ function downloadFile(uri) { $('body').append(frame); } +function uploadFile(uri, input, callback) { + if (!window.FormData) { + showErrorMessage("Your browser doesn't implement this function!");s + callback(); + } + + var formData = new FormData(); + var files = input[0].files; + formData.append('files', files[0], files[0].name); + + ajax('POST', uri, formData, callback); +} + /* apply button */ @@ -1897,6 +1916,7 @@ function doUpdate() { } else { runConfirmDialog('New version available: ' + data.update_version + '. Update?', function () { + refreshInterval = 1000000; showModalDialog('
Updating. This may take a few minutes.
'); ajax('POST', baseUri + 'update/?version=' + data.update_version, null, function () { var count = 0; @@ -1935,6 +1955,101 @@ function doUpdate() { }); } +function doBackup() { + downloadFile(baseUri + 'config/backup/'); +} + +function doRestore() { + var content = + $('' + + '' + + '' + + '' + + '' + + '' + + '
Backup File
?
'); + + /* collect ui widgets */ + var fileInput = content.find('#fileInput'); + + /* make validators */ + makeFileValidator(fileInput, true); + + function uiValid() { + /* re-validate all the validators */ + content.find('.validator').each(function () { + this.validate(); + }); + + var valid = true; + var query = content.find('input, select'); + query.each(function () { + if (this.invalid) { + valid = false; + return false; + } + }); + + return valid; + } + + runModalDialog({ + title: 'Restore Configuration', + closeButton: true, + buttons: 'okcancel', + content: content, + onOk: function () { + if (!uiValid(true)) { + return false; + } + + refreshInterval = 1000000; + + setTimeout(function () { + showModalDialog('
Restoring configuration...
'); + uploadFile(baseUri + 'config/restore/', fileInput, function (data) { + if (data && data.ok) { + var count = 0; + function checkServer() { + ajax('GET', baseUri + 'config/0/get/', null, + function () { + runAlertDialog('The configuration has been restored!', function () { + window.location.reload(true); + }); + }, + function () { + if (count < 25) { + count += 1; + setTimeout(checkServer, 2000); + } + else { + runAlertDialog('Failed to restore the configuration!', function () { + window.location.reload(true); + }); + } + } + ); + } + + if (data.reboot) { + setTimeout(checkServer, 10000); + } + else { + setTimeout(function () { + window.location.reload(); + }, 5000); + } + } + else { + hideModalDialog(); + showErrorMessage('Failed to restore the configuration!'); + } + }); + }, 10); + } + }); +} + function doDownloadZipped(cameraId, groupKey) { showModalDialog('', null, null, true); ajax('GET', baseUri + 'picture/' + cameraId + '/zipped/' + groupKey + '/', null, function (data) { @@ -2636,13 +2751,13 @@ function runAddCameraDialog() { beginProgress(); ajax('POST', baseUri + 'config/add/', data, function (data) { + endProgress(); + if (data == null || data.error) { - endProgress(); showErrorMessage(data && data.error); return; } - endProgress(); var cameraOption = $('#cameraSelect').find('option[value=add]'); cameraOption.before(''); $('#cameraSelect').val(data.id).change(); @@ -3540,6 +3655,10 @@ $(document).ready(function () { /* software update button */ $('div#updateButton').click(doUpdate); + /* backup/restore */ + $('div#backupButton').click(doBackup); + $('div#restoreButton').click(doRestore); + /* prevent scroll events on settings div from propagating TODO this does not actually work */ $('div.settings').mousewheel(function (e, d) { var t = $(this); diff --git a/static/js/ui.js b/static/js/ui.js index 94cf896..4aefc59 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -636,6 +636,59 @@ function makeUrlValidator($input) { }); } +function makeFileValidator($input, required) { + if (required == null) { + required = true; + } + + $input.each(function () { + var $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + if (strVal.length === 0 && required) { + return false; + } + + return true; + } + + var msg = 'this field is required'; + + function validate() { + var strVal = $this.val(); + if (isValid(strVal)) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', msg); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + + $this.addClass('validator'); + $this.addClass('file-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); +} function makeCustomValidator($input, isValidFunc) { $input.each(function () { var $this = $(this); diff --git a/templates/main.html b/templates/main.html index f0904d4..291f66c 100644 --- a/templates/main.html +++ b/templates/main.html @@ -140,20 +140,33 @@ {% if enable_update %} Software Update -
Check
+
Check
? {% endif %} Power -
Shut Down
+
Shut Down
? -
Reboot
+
Reboot
? + +
+ + + Configuration +
Backup
+ ? + + + +
Restore
+ ? + {% for section in main_sections.values() %} @@ -173,7 +186,7 @@ {% endfor %} -
+
-- 2.39.5