From 51b0950ec417b09c2448976e1ca64237efff7998 Mon Sep 17 00:00:00 2001 From: Michael Pimmer Date: Thu, 4 Oct 2018 11:06:28 +0000 Subject: [PATCH] Backups: uploading and import with temporarily stored file Reviewed-by: James Valleroy --- plinth/__main__.py | 1 + plinth/modules/backups/__init__.py | 12 ++++ plinth/modules/backups/forms.py | 21 ++++++ plinth/modules/backups/middleware.py | 48 +++++++++++++ .../templates/backups_restore_from_tmp.html | 45 ++++++++++++ plinth/modules/backups/urls.py | 4 +- plinth/modules/backups/views.py | 69 ++++++++++++++++--- 7 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 plinth/modules/backups/middleware.py create mode 100644 plinth/modules/backups/templates/backups_restore_from_tmp.html diff --git a/plinth/__main__.py b/plinth/__main__.py index 9531aafa3..cfd8378aa 100644 --- a/plinth/__main__.py +++ b/plinth/__main__.py @@ -308,6 +308,7 @@ def configure_django(): 'plinth.middleware.FirstSetupMiddleware', 'plinth.modules.first_boot.middleware.FirstBootMiddleware', 'plinth.middleware.SetupMiddleware', + 'plinth.modules.backups.middleware.BackupsMiddleware', ), ROOT_URLCONF='plinth.urls', SECURE_BROWSER_XSS_FILTER=True, diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py index 3656a779e..697ac2f92 100644 --- a/plinth/modules/backups/__init__.py +++ b/plinth/modules/backups/__init__.py @@ -49,6 +49,8 @@ MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/' BACKUP_FOLDER_NAME = 'FreedomBox-backups' # default backup path for temporary actions like imports or download TMP_BACKUP_PATH = '/tmp/freedombox-backup.tar.gz' +# session variable name that stores when a backup file should be deleted +SESSION_BACKUP_VARIABLE = 'fbx-backup-filestamp' def init(): @@ -109,6 +111,10 @@ def create_archive(name, app_names): def delete_archive(name): actions.superuser_run('backups', ['delete', '--name', name]) +def delete_tmp_backup_file(): + if os.path.isfile(TMP_BACKUP_PATH): + os.remove(TMP_BACKUP_PATH) + def export_archive(name, location, tmp_dir=False): # TODO: find a better solution for distinguishing exports to /tmp @@ -194,6 +200,12 @@ def _restore_handler(packet): input=locations_data.encode()) +def restore_from_tmp(apps=None): + """Restore files from temporary backup file""" + api.restore_apps(_restore_handler, app_names=apps, create_subvolume=False, + backup_file=TMP_BACKUP_PATH) + + def restore_exported(device, archive_name, apps=None): """Restore files from exported backup archive.""" filename = find_exported_archive(device, archive_name) diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py index 0e41e825b..f68119149 100644 --- a/plinth/modules/backups/forms.py +++ b/plinth/modules/backups/forms.py @@ -75,6 +75,20 @@ class ExportArchiveForm(forms.Form): for location in get_export_locations()] +class RestoreFromTmpForm(forms.Form): + selected_apps = forms.MultipleChoiceField( + label=_('Restore apps'), + widget=forms.CheckboxSelectMultiple) + + def __init__(self, *args, **kwargs): + """Initialize the form with selectable apps.""" + apps = kwargs.pop('apps') + super().__init__(*args, **kwargs) + self.fields['selected_apps'].choices = [ + (app[0], app[1].name) for app in apps] + self.fields['selected_apps'].initial = [app[0] for app in apps] + + class RestoreForm(forms.Form): selected_apps = forms.MultipleChoiceField( label=_('Restore apps'), @@ -123,3 +137,10 @@ class UploadForm(forms.Form): "File %s already exists" % file.name) else: self.cleaned_data.update({'filepath': filepath}) + + +class UploadToTmpForm(forms.Form): + file = forms.FileField(label=_('Upload File'), required=True, + validators=[FileExtensionValidator(['gz'], + 'Backup files have to be in .tar.gz format')], + help_text=_('Select the backup file you want to upload')) diff --git a/plinth/modules/backups/middleware.py b/plinth/modules/backups/middleware.py new file mode 100644 index 000000000..566e447cc --- /dev/null +++ b/plinth/modules/backups/middleware.py @@ -0,0 +1,48 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +""" +Django middleware to redirect to firstboot wizard if it has not be run +yet. +""" + +import logging +import random +import time + +from django.utils.deprecation import MiddlewareMixin + +from plinth.modules import backups + +LOGGER = logging.getLogger(__name__) + + +class BackupsMiddleware(MiddlewareMixin): + """Delete outdated backup file.""" + + @staticmethod + def process_request(request): + """Handle a request as Django middleware request handler.""" + if random.random() > 0.9: + if request.session.has_key(backups.SESSION_BACKUP_VARIABLE): + now = time.time() + if now > request.session[backups.SESSION_BACKUP_VARIABLE]: + backups.delete_tmp_backup_file() + del request.session[backups.SESSION_BACKUP_VARIABLE] + else: + backups.delete_tmp_backup_file() + return diff --git a/plinth/modules/backups/templates/backups_restore_from_tmp.html b/plinth/modules/backups/templates/backups_restore_from_tmp.html new file mode 100644 index 000000000..79d442438 --- /dev/null +++ b/plinth/modules/backups/templates/backups_restore_from_tmp.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% comment %} +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} +

{{ title }}

+ +

+

+ {% csrf_token %} + + {{ form|bootstrap }} + + + + {% trans 'Abort' %} + +
+

+ +{% endblock %} diff --git a/plinth/modules/backups/urls.py b/plinth/modules/backups/urls.py index cde0f1528..b4cbc807e 100644 --- a/plinth/modules/backups/urls.py +++ b/plinth/modules/backups/urls.py @@ -22,7 +22,7 @@ from django.conf.urls import url from .views import IndexView, CreateArchiveView, DownloadArchiveView, \ DeleteArchiveView, ExportArchiveView, RestoreView, UploadArchiveView, \ - ExportAndDownloadView + ExportAndDownloadView, RestoreFromTmpView urlpatterns = [ url(r'^sys/backups/$', IndexView.as_view(), name='index'), @@ -38,4 +38,6 @@ urlpatterns = [ url(r'^sys/backups/upload/$', UploadArchiveView.as_view(), name='upload'), url(r'^sys/backups/restore/(?P[^/]+)/(?P[^/]+)/$', RestoreView.as_view(), name='restore'), + url(r'^sys/backups/restore-from-tmp/$', + RestoreFromTmpView.as_view(), name='restore-from-tmp'), ] diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py index 37daafbe8..b19fa9b09 100644 --- a/plinth/modules/backups/views.py +++ b/plinth/modules/backups/views.py @@ -19,8 +19,9 @@ Views for the backups app. """ import mimetypes -import os from datetime import datetime +import os +import time from urllib.parse import unquote from django.contrib import messages @@ -34,8 +35,11 @@ from django.views.generic import View, FormView, TemplateView from plinth.modules import backups -from . import api, find_exported_archive, TMP_BACKUP_PATH, forms +from . import api, find_exported_archive, TMP_BACKUP_PATH, forms, \ + SESSION_BACKUP_VARIABLE, delete_tmp_backup_file +# number of seconds an uploaded backup file should be kept/stored +KEEP_UPLOADED_BACKUP_FOR = 60*10 subsubmenu = [{ 'url': reverse_lazy('backups:index'), @@ -161,16 +165,14 @@ class create_temporary_backup_file: return self.path def __exit__(self, type, value, traceback): - if os.path.isfile(self.path): - os.remove(self.path) + delete_tmp_backup_file() class UploadArchiveView(SuccessMessageMixin, FormView): - form_class = forms.UploadForm + form_class = forms.UploadToTmpForm prefix = 'backups' template_name = 'backups_upload.html' - success_url = reverse_lazy('backups:index') - success_message = _('Backup file uploaded.') + success_url = reverse_lazy('backups:restore-from-tmp') def get_context_data(self, **kwargs): """Return additional context for rendering the template.""" @@ -181,9 +183,11 @@ class UploadArchiveView(SuccessMessageMixin, FormView): def form_valid(self, form): """store uploaded file.""" - with open(form.cleaned_data['filepath'], 'wb+') as destination: + with open(TMP_BACKUP_PATH, 'wb+') as destination: for chunk in self.request.FILES['backups-file'].chunks(): destination.write(chunk) + self.request.session[SESSION_BACKUP_VARIABLE] = time.time() + \ + KEEP_UPLOADED_BACKUP_FOR return super().form_valid(form) @@ -223,7 +227,10 @@ class RestoreView(SuccessMessageMixin, FormView): """Save some data used to instantiate the form.""" device = unquote(self.kwargs['device']) name = unquote(self.kwargs['name']) - filename = backups.find_exported_archive(device, name) + if self.kwargs['use_tmp_file'] == 'true': + filename = TMP_BACKUP_PATH + else: + filename = backups.find_exported_archive(device, name) return backups.get_export_apps(filename) def get_form_kwargs(self): @@ -250,3 +257,47 @@ class RestoreView(SuccessMessageMixin, FormView): unquote(self.kwargs['device']), self.kwargs['name'], form.cleaned_data['selected_apps']) return super().form_valid(form) + + +class RestoreFromTmpView(SuccessMessageMixin, FormView): + """View to restore files from an exported archive. + + TODO: combine with RestoreView""" + # TODO: display more information about the backup, like the date + form_class = forms.RestoreFromTmpForm + prefix = 'backups' + template_name = 'backups_restore_from_tmp.html' + success_url = reverse_lazy('backups:index') + success_message = _('Restored files from backup.') + + def get(self, *args, **kwargs): + if not os.path.isfile(TMP_BACKUP_PATH): + messages.error(self.request, _('No backup file found.')) + return redirect(reverse_lazy('backups:index')) + else: + return super().get(*args, **kwargs) + + def _get_included_apps(self): + """Save some data used to instantiate the form.""" + return backups.get_export_apps(TMP_BACKUP_PATH) + + def get_form_kwargs(self): + """Pass additional keyword args for instantiating the form.""" + kwargs = super().get_form_kwargs() + included_apps = self._get_included_apps() + installed_apps = api.get_all_apps_for_backup() + kwargs['apps'] = [ + app for app in installed_apps if app[0] in included_apps + ] + return kwargs + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Restore data') + return context + + def form_valid(self, form): + """Restore files from the archive on valid form submission.""" + backups.restore_from_tmp(form.cleaned_data['selected_apps']) + return super().form_valid(form)