# 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
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':
elif op == 'list_devices':
self.list_devices()
+
+ elif op == 'backup':
+ self.backup()
else:
raise HTTPError(400, 'unknown operation')
elif op == 'rem':
self.rem_camera(camera_id)
+
+ elif op == 'restore':
+ self.restore()
elif op == '_relay_event':
self._relay_event(camera_id)
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):
(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),
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()
div.settings-item-separator {
height: 1px;
border-top: 1px solid #414141;
- margin: 5px 0px;
+ margin: 0.5em 1em;
}
#cameraSelect {
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;
width: 7em;
}
-div.update-button {
+div.update-button,
+div.backup-button,
+div.restore-button {
background: #317CAD;
}
background: #c0392b;
}
-div.update-button:HOVER {
+div.update-button:HOVER,
+div.backup-button:HOVER,
+div.restore-button:HOVER {
background: #3498db;
}
background: #D43F2F;
}
-div.update-button:ACTIVE {
+div.update-button:ACTIVE,
+div.backup-button:ACTIVE,
+div.restore-button:ACTIVE {
background: #317CAD;
}
}
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;
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;
}
}
}
- url = addAuthParams(method, url, data);
+ url = addAuthParams(method, url, processData ? data : null);
var options = {
type: method,
}
}
},
- contentType: json ? 'application/json' : null,
+ contentType: json ? 'application/json' : false,
+ processData: processData,
error: error || function (request, options, error) {
showErrorMessage();
if (callback) {
$('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 */
}
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;
});
}
+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) {
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();
/* 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);
});
}
+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);
{% 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">