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 }}
+
+
+
+
+
+{% 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)