Backups: uploading and import with temporarily stored file

Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Michael Pimmer 2018-10-04 11:06:28 +00:00 committed by James Valleroy
parent ff673b0d73
commit 51b0950ec4
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
7 changed files with 190 additions and 10 deletions

View File

@ -308,6 +308,7 @@ def configure_django():
'plinth.middleware.FirstSetupMiddleware', 'plinth.middleware.FirstSetupMiddleware',
'plinth.modules.first_boot.middleware.FirstBootMiddleware', 'plinth.modules.first_boot.middleware.FirstBootMiddleware',
'plinth.middleware.SetupMiddleware', 'plinth.middleware.SetupMiddleware',
'plinth.modules.backups.middleware.BackupsMiddleware',
), ),
ROOT_URLCONF='plinth.urls', ROOT_URLCONF='plinth.urls',
SECURE_BROWSER_XSS_FILTER=True, SECURE_BROWSER_XSS_FILTER=True,

View File

@ -49,6 +49,8 @@ MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
BACKUP_FOLDER_NAME = 'FreedomBox-backups' BACKUP_FOLDER_NAME = 'FreedomBox-backups'
# default backup path for temporary actions like imports or download # default backup path for temporary actions like imports or download
TMP_BACKUP_PATH = '/tmp/freedombox-backup.tar.gz' 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(): def init():
@ -109,6 +111,10 @@ def create_archive(name, app_names):
def delete_archive(name): def delete_archive(name):
actions.superuser_run('backups', ['delete', '--name', 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): def export_archive(name, location, tmp_dir=False):
# TODO: find a better solution for distinguishing exports to /tmp # TODO: find a better solution for distinguishing exports to /tmp
@ -194,6 +200,12 @@ def _restore_handler(packet):
input=locations_data.encode()) 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): def restore_exported(device, archive_name, apps=None):
"""Restore files from exported backup archive.""" """Restore files from exported backup archive."""
filename = find_exported_archive(device, archive_name) filename = find_exported_archive(device, archive_name)

View File

@ -75,6 +75,20 @@ class ExportArchiveForm(forms.Form):
for location in get_export_locations()] 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): class RestoreForm(forms.Form):
selected_apps = forms.MultipleChoiceField( selected_apps = forms.MultipleChoiceField(
label=_('Restore apps'), label=_('Restore apps'),
@ -123,3 +137,10 @@ class UploadForm(forms.Form):
"File %s already exists" % file.name) "File %s already exists" % file.name)
else: else:
self.cleaned_data.update({'filepath': filepath}) 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'))

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h2>{{ title }}</h2>
<p>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% blocktrans trimmed %}
Restore data
{% endblocktrans %}"/>
<a title="{% trans 'Abort' %}"
role="button" class="btn btn-warning"
href="{% url 'backups:index' %}">
{% trans 'Abort' %}
</a>
</form>
</p>
{% endblock %}

View File

@ -22,7 +22,7 @@ from django.conf.urls import url
from .views import IndexView, CreateArchiveView, DownloadArchiveView, \ from .views import IndexView, CreateArchiveView, DownloadArchiveView, \
DeleteArchiveView, ExportArchiveView, RestoreView, UploadArchiveView, \ DeleteArchiveView, ExportArchiveView, RestoreView, UploadArchiveView, \
ExportAndDownloadView ExportAndDownloadView, RestoreFromTmpView
urlpatterns = [ urlpatterns = [
url(r'^sys/backups/$', IndexView.as_view(), name='index'), 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/upload/$', UploadArchiveView.as_view(), name='upload'),
url(r'^sys/backups/restore/(?P<device>[^/]+)/(?P<name>[^/]+)/$', url(r'^sys/backups/restore/(?P<device>[^/]+)/(?P<name>[^/]+)/$',
RestoreView.as_view(), name='restore'), RestoreView.as_view(), name='restore'),
url(r'^sys/backups/restore-from-tmp/$',
RestoreFromTmpView.as_view(), name='restore-from-tmp'),
] ]

View File

@ -19,8 +19,9 @@ Views for the backups app.
""" """
import mimetypes import mimetypes
import os
from datetime import datetime from datetime import datetime
import os
import time
from urllib.parse import unquote from urllib.parse import unquote
from django.contrib import messages from django.contrib import messages
@ -34,8 +35,11 @@ from django.views.generic import View, FormView, TemplateView
from plinth.modules import backups 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 = [{ subsubmenu = [{
'url': reverse_lazy('backups:index'), 'url': reverse_lazy('backups:index'),
@ -161,16 +165,14 @@ class create_temporary_backup_file:
return self.path return self.path
def __exit__(self, type, value, traceback): def __exit__(self, type, value, traceback):
if os.path.isfile(self.path): delete_tmp_backup_file()
os.remove(self.path)
class UploadArchiveView(SuccessMessageMixin, FormView): class UploadArchiveView(SuccessMessageMixin, FormView):
form_class = forms.UploadForm form_class = forms.UploadToTmpForm
prefix = 'backups' prefix = 'backups'
template_name = 'backups_upload.html' template_name = 'backups_upload.html'
success_url = reverse_lazy('backups:index') success_url = reverse_lazy('backups:restore-from-tmp')
success_message = _('Backup file uploaded.')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Return additional context for rendering the template.""" """Return additional context for rendering the template."""
@ -181,9 +183,11 @@ class UploadArchiveView(SuccessMessageMixin, FormView):
def form_valid(self, form): def form_valid(self, form):
"""store uploaded file.""" """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(): for chunk in self.request.FILES['backups-file'].chunks():
destination.write(chunk) destination.write(chunk)
self.request.session[SESSION_BACKUP_VARIABLE] = time.time() + \
KEEP_UPLOADED_BACKUP_FOR
return super().form_valid(form) return super().form_valid(form)
@ -223,7 +227,10 @@ class RestoreView(SuccessMessageMixin, FormView):
"""Save some data used to instantiate the form.""" """Save some data used to instantiate the form."""
device = unquote(self.kwargs['device']) device = unquote(self.kwargs['device'])
name = unquote(self.kwargs['name']) 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) return backups.get_export_apps(filename)
def get_form_kwargs(self): def get_form_kwargs(self):
@ -250,3 +257,47 @@ class RestoreView(SuccessMessageMixin, FormView):
unquote(self.kwargs['device']), self.kwargs['name'], unquote(self.kwargs['device']), self.kwargs['name'],
form.cleaned_data['selected_apps']) form.cleaned_data['selected_apps'])
return super().form_valid(form) 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)