implemented configuration backup and restore
authorCalin Crisan <ccrisan@gmail.com>
Sun, 15 Mar 2015 12:46:25 +0000 (14:46 +0200)
committerCalin Crisan <ccrisan@gmail.com>
Sun, 15 Mar 2015 12:46:25 +0000 (14:46 +0200)
src/config.py
src/handlers.py
src/server.py
src/utils.py
static/css/main.css
static/css/ui.css
static/js/main.js
static/js/ui.js
templates/main.html

index 35420e3df47a88a0325488b86e6305dc595c9d2d..93b4aa9ff32d312b16f69b3cc7236b10daefa8f3 100644 (file)
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>. 
 
+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':
index d390fe39692b3619e28106c199393df036545563..dc0bf95455ee58dfe26d0caeec6b372f4172899b 100644 (file)
@@ -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):
index 19b3d4ac8cf593af298831e52854cb16bb38de61..2e6bf1074309bf589624be11f86f56ef427ea713 100644 (file)
@@ -43,7 +43,7 @@ application = Application(
         (r'^/$', handlers.MainHandler),
         (r'^/config/main/(?P<op>set|get)/?$', handlers.ConfigHandler),
         (r'^/config/(?P<camera_id>\d+)/(?P<op>get|set|rem|set_preview|_relay_event)/?$', handlers.ConfigHandler),
-        (r'^/config/(?P<op>add|list|list_devices)/?$', handlers.ConfigHandler),
+        (r'^/config/(?P<op>add|list|list_devices|backup|restore)/?$', handlers.ConfigHandler),
         (r'^/picture/(?P<camera_id>\d+)/(?P<op>current|list|frame)/?$', handlers.PictureHandler),
         (r'^/picture/(?P<camera_id>\d+)/(?P<op>download|preview|delete)/(?P<filename>.+?)/?$', handlers.PictureHandler),
         (r'^/picture/(?P<camera_id>\d+)/(?P<op>zipped|timelapse|delete_all)/(?P<group>.+?)/?$', handlers.PictureHandler),
index 1ae4d9209ef9515ed8a35adc9ad9ff37ed992efa..db32598307ca2f0c6c328861d84d01d9c69d9c34 100644 (file)
@@ -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()
 
 
index fd938d893424cb17db278efe0bb0694a777651f8..95c44bfe43003fe96ef099343a4e487d5a33c8e4 100644 (file)
@@ -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;
 }
 
index 823f74b6bc69f97f26b3fc5612d1e37762e8c4bc..15a33b3bf96c1fdd3a641c91114457c7463028ee 100644 (file)
@@ -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;
index 4a9dbbc4c8688e1116f96899a9ffc4324e2c747d..16869a9f08ca7e28d26ceda1cee8b9e8b1d73ff8 100644 (file)
@@ -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('<div style="text-align: center;"><span>Updating. This may take a few minutes.</span><div class="modal-progress"></div></div>');
                 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 = 
+            $('<table class="restore-dialog">' +
+                '<tr>' +
+                    '<td class="dialog-item-label"><span class="dialog-item-label">Backup File</span></td>' +
+                    '<td class="dialog-item-value"><form><input type="file" class="styled" id="fileInput"></form></td>' +
+                    '<td><span class="help-mark" title="the device you wish to add to motionEye">?</span></td>' +
+                '</tr>' +
+            '</table>');
+    
+    /* 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('<div style="text-align: center;"><span>Restoring configuration...</span><div class="modal-progress"></div></div>');
+                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('<div class="modal-progress"></div>', 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('<option value="' + data.id + '">' + data.name + '</option>');
                     $('#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);
index 94cf89685eb642eaf804042b513350fdb196eac8..4aefc592ebf34b491e6cddaeaf0db14027484f57 100644 (file)
@@ -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);
index f0904d414b8a3e60d7000c86b6050d978d00d9ac..291f66c48e10fb5c8ff9c0cb7c5aa8be822f8a42 100644 (file)
                     {% if enable_update %}
                     <tr class="settings-item advanced-setting">
                         <td class="settings-item-label"><span class="settings-item-label">Software Update</span></td>
-                        <td class="settings-item-value"><div class="button update-button" id="updateButton">Check</div></td>
+                        <td class="settings-item-value"><div class="button normal-button update-button" id="updateButton">Check</div></td>
                         <td><span class="help-mark" title="checks for new versions and performs updates">?</span></td>
                     </tr>
                     {% endif %}
                     <tr class="settings-item advanced-setting{% if not enable_reboot %} hidden{% endif %}">
                         <td class="settings-item-label"><span class="settings-item-label">Power</span></td>
-                        <td class="settings-item-value"><div class="button shut-down-button" id="shutDownButton">Shut Down</div></td>
+                        <td class="settings-item-value"><div class="button normal-button shut-down-button" id="shutDownButton">Shut Down</div></td>
                         <td><span class="help-mark" title="shuts down the system">?</span></td>
                     </tr>
                     <tr class="settings-item advanced-setting{% if not enable_reboot %} hidden{% endif %}">
                         <td class="settings-item-label"><span class="settings-item-label"></span></td>
-                        <td class="settings-item-value"><div class="button reboot-button" id="rebootButton">Reboot</div></td>
+                        <td class="settings-item-value"><div class="button normal-button reboot-button" id="rebootButton">Reboot</div></td>
                         <td><span class="help-mark" title="reboots the system">?</span></td>
                     </tr>
+                    <tr class="settings-item advanced-setting">
+                        <td colspan="100"><div class="settings-item-separator"></div></td>
+                    </tr>
+                    <tr class="settings-item advanced-setting">
+                        <td class="settings-item-label"><span class="settings-item-label">Configuration</span></td>
+                        <td class="settings-item-value"><div class="button normal-button backup-button" id="backupButton">Backup</div></td>
+                        <td><span class="help-mark" title="creates a file with the current configuration for you to save it locally">?</span></td>
+                    </tr>
+                    <tr class="settings-item advanced-setting">
+                        <td class="settings-item-label"><span class="settings-item-label"></span></td>
+                        <td class="settings-item-value"><div class="button normal-button restore-button" id="restoreButton">Restore</div></td>
+                        <td><span class="help-mark" title="restores the configuration from a previously saved backup file">?</span></td>
+                    </tr>
                 </table>
 
                 {% for section in main_sections.values() %}
                 {% endfor %}
 
                 <tr class="settings-item advanced-setting">
-                    <td colspan="100"><div class="settings-item-separator" style="margin-bottom: 1.5em;"></div></td>
+                    <td colspan="100"><div class="settings-item-separator" style="margin: 0px -1em 1.5em -1em;"></div></td>
                 </tr>
                 
                 <div class="settings-section-title">