diff --git a/actions/backups b/actions/backups
new file mode 100755
index 000000000..e84982194
--- /dev/null
+++ b/actions/backups
@@ -0,0 +1,122 @@
+#!/usr/bin/python3
+# -*- mode: python -*-
+#
+# 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 .
+#
+"""
+Configuration helper for backups.
+"""
+
+import argparse
+import os
+import subprocess
+
+REPOSITORY = '/tmp/freedombox-backups'
+
+
+def parse_arguments():
+ """Return parsed command line arguments as dictionary."""
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
+
+ subparsers.add_parser(
+ 'setup', help='Create repository if it does not already exist')
+ subparsers.add_parser('info', help='Show repository information')
+ subparsers.add_parser('list', help='List repository contents')
+
+ create = subparsers.add_parser('create', help='Create archive')
+ create.add_argument('--name', help='Archive name', required=True)
+ create.add_argument('--path', help='Path to archive', required=True)
+
+ delete = subparsers.add_parser('delete', help='Delete archive')
+ delete.add_argument('--name', help='Archive name', required=True)
+
+ extract = subparsers.add_parser('extract', help='Extract archive contents')
+ extract.add_argument('--name', help='Archive name', required=True)
+ extract.add_argument('--destination', help='Extract destination',
+ required=True)
+
+ export = subparsers.add_parser('export',
+ help='Export archive contents as tarball')
+ export.add_argument('--name', help='Archive name', required=True)
+ export.add_argument('--filename', help='Tarball file name', required=True)
+
+ subparsers.required = True
+ return parser.parse_args()
+
+
+def subcommand_setup(_):
+ """Create repository if it does not already exist."""
+ try:
+ subprocess.run(['borg', 'info', REPOSITORY], check=True)
+ except:
+ subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY])
+
+
+def subcommand_info(_):
+ """Show repository information."""
+ subprocess.run(['borg', 'info', '--json', REPOSITORY], check=True)
+
+
+def subcommand_list(_):
+ """List repository contents."""
+ subprocess.run(['borg', 'list', '--json', REPOSITORY], check=True)
+
+
+def subcommand_create(arguments):
+ """Create archive."""
+ subprocess.run([
+ 'borg', 'create', '--json', REPOSITORY + '::' + arguments.name,
+ arguments.path
+ ], check=True)
+
+
+def subcommand_delete(arguments):
+ """Delete archive."""
+ subprocess.run(['borg', 'delete', REPOSITORY + '::' + arguments.name],
+ check=True)
+
+
+def subcommand_extract(arguments):
+ """Extract archive contents."""
+ prev_dir = os.getcwd()
+ try:
+ os.chdir(os.path.expanduser(arguments.destination))
+ subprocess.run(['borg', 'extract', REPOSITORY + '::' + arguments.name],
+ check=True)
+ finally:
+ os.chdir(prev_dir)
+
+
+def subcommand_export(arguments):
+ """Export archive contents as tarball."""
+ subprocess.run([
+ 'borg', 'export-tar', REPOSITORY + '::' + arguments.name,
+ arguments.filename
+ ], check=True)
+
+
+def main():
+ """Parse arguments and perform all duties."""
+ arguments = parse_arguments()
+
+ subcommand = arguments.subcommand.replace('-', '_')
+ subcommand_method = globals()['subcommand_' + subcommand]
+ subcommand_method(arguments)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/data/etc/plinth/modules-enabled/backups b/data/etc/plinth/modules-enabled/backups
new file mode 100644
index 000000000..0e2d89ecf
--- /dev/null
+++ b/data/etc/plinth/modules-enabled/backups
@@ -0,0 +1 @@
+plinth.modules.backups
diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py
new file mode 100644
index 000000000..dae7dd23f
--- /dev/null
+++ b/plinth/modules/backups/__init__.py
@@ -0,0 +1,85 @@
+#
+# 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 .
+#
+"""
+FreedomBox app to manage backup archives.
+"""
+
+import json
+
+from django.utils.translation import ugettext_lazy as _
+
+from plinth import actions
+from plinth.menu import main_menu
+
+version = 1
+
+managed_packages = ['borgbackup']
+
+name = _('Backups')
+
+description = [_('Backups allows creating and managing backup archives.'), ]
+
+service = None
+
+
+def init():
+ """Intialize the module."""
+ menu = main_menu.get('system')
+ menu.add_urlname(name, 'glyphicon-duplicate', 'backups:index')
+
+
+def setup(helper, old_version=None):
+ """Install and configure the module."""
+ helper.install(managed_packages)
+ helper.call('post', actions.superuser_run, 'backups', ['setup'])
+
+
+def get_info():
+ output = actions.superuser_run('backups', ['info'])
+ return json.loads(output)
+
+
+def list_archives():
+ output = actions.superuser_run('backups', ['list'])
+ return json.loads(output)['archives']
+
+
+def get_archive(name):
+ for archive in list_archives():
+ if archive['name'] == name:
+ return archive
+
+ return None
+
+
+def create_archive(name, path):
+ actions.superuser_run('backups',
+ ['create', '--name', name, '--path', path])
+
+
+def delete_archive(name):
+ actions.superuser_run('backups', ['delete', '--name', name])
+
+
+def extract_archive(name, destination):
+ actions.superuser_run(
+ 'backups', ['extract', '--name', name, '--destination', destination])
+
+
+def export_archive(name, filename):
+ actions.superuser_run('backups',
+ ['export', '--name', name, '--filename', filename])
diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py
new file mode 100644
index 000000000..90f0daa14
--- /dev/null
+++ b/plinth/modules/backups/forms.py
@@ -0,0 +1,43 @@
+#
+# 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 .
+#
+"""
+Forms for backups module.
+"""
+
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+
+
+class CreateArchiveForm(forms.Form):
+ name = forms.CharField(label=_('Archive name'), strip=True,
+ help_text=_('Name for new backup archive.'))
+
+ path = forms.CharField(label=_('Path'), strip=True, help_text=_(
+ 'Disk path to a folder on this server that will be archived into '
+ 'backup repository.'))
+
+
+class ExtractArchiveForm(forms.Form):
+ path = forms.CharField(label=_('Path'), strip=True, help_text=_(
+ 'Disk path to a folder on this server where the archive will be '
+ 'extracted.'))
+
+
+class ExportArchiveForm(forms.Form):
+ filename = forms.CharField(
+ label=_('Exported filename'), strip=True,
+ help_text=_('Name for the tar file exported from the archive.'))
diff --git a/plinth/modules/backups/templates/backups.html b/plinth/modules/backups/templates/backups.html
new file mode 100644
index 000000000..6121e396a
--- /dev/null
+++ b/plinth/modules/backups/templates/backups.html
@@ -0,0 +1,89 @@
+{% 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 i18n %}
+
+{% block page_head %}
+
+{% endblock %}
+
+{% block content %}
+
+
{{ title }}
+
+ {% for paragraph in description %}
+ {{ paragraph|safe }}
+ {% endfor %}
+
+
+
+ {% trans 'Create archive' %}
+
+
+
+ {% if not archives %}
+ {% trans 'No archives currently exist.' %}
+ {% else %}
+
+
+
+ | {% trans "Name" %} |
+ {% trans "Time" %} |
+ |
+
+
+
+
+ {% for archive in archives %}
+
+ | {{ archive.name }} |
+ {{ archive.time }} |
+
+
+
+ {% trans "Export" %}
+
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/plinth/modules/backups/templates/backups_delete.html b/plinth/modules/backups/templates/backups_delete.html
new file mode 100644
index 000000000..b7fa50a70
--- /dev/null
+++ b/plinth/modules/backups/templates/backups_delete.html
@@ -0,0 +1,57 @@
+{% 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 }}
+
+ {% trans "Delete this archive permanently?" %}
+
+
+
+
+
+ | {% trans "Name" %} |
+ {% trans "Time" %} |
+
+
+
+ | {{ archive.name }} |
+ {{ archive.time }} |
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/plinth/modules/backups/templates/backups_form.html b/plinth/modules/backups/templates/backups_form.html
new file mode 100644
index 000000000..97150a2a7
--- /dev/null
+++ b/plinth/modules/backups/templates/backups_form.html
@@ -0,0 +1,37 @@
+{% 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
new file mode 100644
index 000000000..53b419419
--- /dev/null
+++ b/plinth/modules/backups/urls.py
@@ -0,0 +1,35 @@
+#
+# 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 .
+#
+"""
+URLs for the backups module.
+"""
+
+from django.conf.urls import url
+
+from .views import IndexView, CreateArchiveView, DeleteArchiveView, \
+ ExtractArchiveView, ExportArchiveView
+
+urlpatterns = [
+ url(r'^sys/backups/$', IndexView.as_view(), name='index'),
+ url(r'^sys/backups/create/$', CreateArchiveView.as_view(), name='create'),
+ url(r'^sys/backups/(?P[a-z0-9]+)/delete/$',
+ DeleteArchiveView.as_view(), name='delete'),
+ url(r'^sys/backups/(?P[a-z0-9]+)/extract/$',
+ ExtractArchiveView.as_view(), name='extract'),
+ url(r'^sys/backups/(?P[a-z0-9]+)/export/$',
+ ExportArchiveView.as_view(), name='export'),
+]
diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py
new file mode 100644
index 000000000..673c52596
--- /dev/null
+++ b/plinth/modules/backups/views.py
@@ -0,0 +1,136 @@
+#
+# 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 .
+#
+"""
+Views for the backups app.
+"""
+
+from django.contrib import messages
+from django.contrib.messages.views import SuccessMessageMixin
+from django.http import Http404
+from django.shortcuts import redirect
+from django.urls import reverse_lazy
+from django.utils.translation import ugettext as _
+from django.views.generic import FormView, TemplateView
+
+from plinth.modules import backups
+
+from .forms import CreateArchiveForm, ExtractArchiveForm, ExportArchiveForm
+
+
+class IndexView(TemplateView):
+ """View to show list of archives."""
+ template_name = 'backups.html'
+
+ def get_context_data(self, **kwargs):
+ """Return additional context for rendering the template."""
+ context = super().get_context_data(**kwargs)
+ context['title'] = backups.name
+ context['description'] = backups.description
+ context['info'] = backups.get_info()
+ context['archives'] = backups.list_archives()
+ return context
+
+
+class CreateArchiveView(SuccessMessageMixin, FormView):
+ """View to create a new archive."""
+ form_class = CreateArchiveForm
+ prefix = 'backups'
+ template_name = 'backups_form.html'
+ success_url = reverse_lazy('backups:index')
+ success_message = _('Archive created.')
+
+ def get_context_data(self, **kwargs):
+ """Return additional context for rendering the template."""
+ context = super().get_context_data(**kwargs)
+ context['title'] = _('Create Archive')
+ return context
+
+ def form_valid(self, form):
+ """Create the archive on valid form submission."""
+ backups.create_archive(form.cleaned_data['name'],
+ form.cleaned_data['path'])
+ return super().form_valid(form)
+
+
+class DeleteArchiveView(SuccessMessageMixin, TemplateView):
+ """View to delete an archive."""
+ template_name = 'backups_delete.html'
+
+ def get_context_data(self, **kwargs):
+ """Return additional context for rendering the template."""
+ context = super().get_context_data(**kwargs)
+ context['title'] = _('Delete Archive')
+ context['archive'] = backups.get_archive(self.kwargs['name'])
+ if context['archive'] is None:
+ raise Http404
+
+ return context
+
+ def post(self, request, name):
+ """Delete the archive."""
+ backups.delete_archive(name)
+ messages.success(request, _('Archive deleted.'))
+ return redirect(reverse_lazy('backups:index'))
+
+
+class ExtractArchiveView(SuccessMessageMixin, FormView):
+ """View to extract an archive."""
+ form_class = ExtractArchiveForm
+ prefix = 'backups'
+ template_name = 'backups_form.html'
+ success_url = reverse_lazy('backups:index')
+ success_message = _('Archive extracted.')
+
+ def get_context_data(self, **kwargs):
+ """Return additional context for rendering the template."""
+ context = super().get_context_data(**kwargs)
+ context['title'] = _('Extract Archive')
+ context['archive'] = backups.get_archive(self.kwargs['name'])
+ if context['archive'] is None:
+ raise Http404
+
+ return context
+
+ def form_valid(self, form):
+ """Create the archive on valid form submission."""
+ backups.extract_archive(self.kwargs['name'], form.cleaned_data['path'])
+ return super().form_valid(form)
+
+
+class ExportArchiveView(SuccessMessageMixin, FormView):
+ """View to export an archive."""
+ form_class = ExportArchiveForm
+ prefix = 'backups'
+ template_name = 'backups_form.html'
+ success_url = reverse_lazy('backups:index')
+ success_message = _('Archive exported.')
+
+ def get_context_data(self, **kwargs):
+ """Return additional context for rendering the template."""
+ context = super().get_context_data(**kwargs)
+ context['title'] = _('Export Archive')
+ context['archive'] = backups.get_archive(self.kwargs['name'])
+ if context['archive'] is None:
+ raise Http404
+
+ return context
+
+ def form_valid(self, form):
+ """Create the archive on valid form submission."""
+ backups.export_archive(self.kwargs['name'],
+ form.cleaned_data['filename'])
+ return super().form_valid(form)