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.modules.first_boot.middleware.FirstBootMiddleware',
'plinth.middleware.SetupMiddleware',
'plinth.modules.backups.middleware.BackupsMiddleware',
),
ROOT_URLCONF='plinth.urls',
SECURE_BROWSER_XSS_FILTER=True,

View File

@ -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)

View File

@ -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'))

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, \
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<device>[^/]+)/(?P<name>[^/]+)/$',
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 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)