diff --git a/.gitignore b/.gitignore index fe23a6fe4..11c70b289 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ plinth/tests/coverage/report/ .idea/ .DS_Store *.box +.eggs diff --git a/LICENSES b/LICENSES index 443f8b7d9..5d00a3b57 100644 --- a/LICENSES +++ b/LICENSES @@ -44,6 +44,7 @@ otherwise. - static/themes/default/img/network-wireless.svg :: [[http://tango.freedesktop.org/][Public Domain]] - static/themes/default/icons/deluge.png :: [[https://upload.wikimedia.org/wikipedia/commons/thumb/8/85//Deluge-Logo.svg/2000px-Deluge-Logo.svg.png][GPL]] - static/themes/default/icons/diaspora.png :: [[https://upload.wikimedia.org/wikipedia/commons/thumb/8/85//Deluge-Logo.svg/2000px-Deluge-Logo.svg.png][Publc Domain]] +- static/themes/default/icons/ejabberd.png :: [[https://www.ejabberd.im/][GPL-2]] - static/themes/default/icons/infinoted.png :: [[https://github.com/gobby/gobby/blob/master/COPYING][ISC]] - static/themes/default/icons/ikiwiki.png :: [[https://ikiwiki.info/][GPL-2+]] - static/themes/default/icons/jsxc.png :: - @@ -58,6 +59,6 @@ otherwise. - static/themes/default/icons/roundcube.png :: [[https://roundcube.net/][GPL-3+]] - static/themes/default/icons/shaarli.png :: [[https://github.com/shaarli/Shaarli][zlib/libpng]] - static/themes/default/icons/syncthing.png :: [[https://github.com/syncthing/syncthing/][Mozilla Public License Version 2.0]] +- static/themes/default/icons/tahoe.png :: [[https://github.com/thekishanraval/Logos][GPLv3+]] - static/themes/default/icons/transmission.png :: [[https://transmissionbt.com/][GPL]] - static/themes/default/icons/ttrss.png :: [[https://tt-rss.org/gitlab/fox/tt-rss][GPL]] -- static/themes/default/icons/ejabberd.png :: [[https://www.ejabberd.im/][GPL-2]] diff --git a/actions/tahoe-lafs b/actions/tahoe-lafs new file mode 100755 index 000000000..c4770af56 --- /dev/null +++ b/actions/tahoe-lafs @@ -0,0 +1,260 @@ +#!/usr/bin/python3 +# -*- mode: python -*- +# +# This file is part of Plinth. +# +# 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 Tahoe-LAFS. +""" + +import argparse +import augeas +import grp +import json +import pwd +import shutil +import subprocess + +import os +import ruamel.yaml + +from plinth import action_utils +from plinth.modules.tahoe import ( + introducer_name, + introducers_file, + storage_node_name, + tahoe_home, + introducer_furl_file) +from plinth.modules.tahoe.errors import TahoeConfigurationError +from plinth.utils import YAMLFile + + +domain_name_file = os.path.join(tahoe_home, 'domain_name') + +DEFAULT_FILE = '/etc/default/tahoe-lafs' + + +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('enable', help='Enable Tahoe-LAFS') + subparsers.add_parser('disable', help='Disable Tahoe-LAFS') + setup = subparsers.add_parser('setup', + help='Set domain name for Tahoe-LAFS') + setup.add_argument('--domain-name', + help='The domain name to be used by Tahoe-LAFS') + subparsers.add_parser('create-introducer', + help='Create and start the introducer node') + subparsers.add_parser('create-storage-node', + help='Create and start the storage node') + subparsers.add_parser('autostart', + help="Automatically start all introducers and " + "storage nodes on system startup") + intro_parser_add = subparsers.add_parser( + 'add-introducer', help="Add an introducer to the storage node's list " + "of introducers.") + intro_parser_add.add_argument( + '--introducer', help="Add an introducer to the storage node's list " + "of introducers Param introducer must be a tuple " + "of (pet_name, furl)") + intro_parser_remove = subparsers.add_parser( + 'remove-introducer', help="Rename the introducer entry in the " + "introducers.yaml file specified by the " + "param") + intro_parser_remove.add_argument( + '--pet-name', help='The domain name that will be used by ' + 'Tahoe-LAFS') + subparsers.add_parser('get-introducers', + help="Return a dictionary of all introducers and " + "their furls added to the storage node running " + "on this FreedomBox.") + subparsers.add_parser('get-local-introducer', + help="Return the name and furl of the introducer " + "created on this FreedomBox") + + return parser.parse_args() + + +def subcommand_setup(arguments): + """Actions to be performed after installing Tahoe-LAFS.""" + # Create tahoe group if needed. + try: + grp.getgrnam('tahoe-lafs') + except KeyError: + subprocess.run(['addgroup', 'tahoe-lafs'], check=True) + + # Create tahoe user if needed. + try: + pwd.getpwnam('tahoe-lafs') + except KeyError: + subprocess.run( + [ + 'adduser', '--system', '--ingroup', 'tahoe-lafs', + '--home', '/var/lib/tahoe-lafs', + '--gecos', 'Tahoe-LAFS distributed file system', 'tahoe-lafs' + ], + check=True) + + if not os.path.exists(tahoe_home): + os.makedirs(tahoe_home, mode=0o755) + + shutil.chown(tahoe_home, user='tahoe-lafs', group='tahoe-lafs') + + if not os.path.exists(domain_name_file): + with open(domain_name_file, 'w') as dnf: + dnf.write(arguments.domain_name) + + +def subcommand_autostart(_): + """Automatically start all introducers and storage nodes on system startup. + """ + aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + + augeas.Augeas.NO_MODL_AUTOLOAD) + aug.set('/augeas/load/Shellvars/lens', 'Shellvars.lns') + aug.set('/augeas/load/Shellvars/incl[last() + 1]', DEFAULT_FILE) + aug.load() + + aug.set('/files' + DEFAULT_FILE + '/AUTOSTART', 'all') + aug.save() + + +def get_configured_domain_name(): + """Extract and return the domain name from the domain name file. + + Throws TahoeConfigurationError if the domain name file is not found. + """ + if not os.path.exists(domain_name_file): + raise TahoeConfigurationError + else: + with open(domain_name_file) as dnf: + return dnf.read().rstrip() + + +def subcommand_create_introducer(_): + """Create a Tahoe-LAFS introducer on this FreedomBox.""" + os.chdir(tahoe_home) + + if not os.path.exists(os.path.join(tahoe_home, introducer_name)): + subprocess.check_call([ + 'tahoe', 'create-introducer', '--port=3456', + '--location=tcp:{}:3456'.format(get_configured_domain_name()), + introducer_name + ]) + + subprocess.call(['tahoe', 'start', introducer_name]) + + +def subcommand_create_storage_node(_): + """Create a Tahoe-LAFS storage node on this FreedomBox.""" + os.chdir(tahoe_home) + + if not os.path.exists(os.path.join(tahoe_home, storage_node_name)): + subprocess.check_call([ + 'tahoe', 'create-node', '--nickname=\"storage_node\"', + '--webport=1234', + '--hostname={}'.format(get_configured_domain_name()), + storage_node_name + ]) + with open( + os.path.join(tahoe_home, introducer_name, 'private', + introducer_name + '.furl'), 'r') as furl_file: + furl = furl_file.read().rstrip() + conf_dict = {'introducers': {introducer_name: {'furl': furl}}} + conf_yaml = ruamel.yaml.dump( + conf_dict, Dumper=ruamel.yaml.RoundTripDumper) + with open( + os.path.join(tahoe_home, storage_node_name, 'private', + 'introducers.yaml'), 'w') as introducers_file: + introducers_file.write(conf_yaml) + + subprocess.call(['tahoe', 'start', storage_node_name]) + + +def subcommand_add_introducer(arguments): + """Add an introducer to the storage node's list of introducers. + + Param introducer must be a tuple of (pet_name, furl). + """ + with YAMLFile(introducers_file, restart_storage_node) as conf: + pet_name, furl = arguments.introducer.split(",") + conf['introducers'][pet_name] = {'furl': furl} + + +def subcommand_remove_introducer(arguments): + """Rename the introducer entry in the introducers.yaml file specified + by the param pet_name + """ + with YAMLFile(introducers_file, restart_storage_node) as conf: + del conf['introducers'][arguments.pet_name] + + +def subcommand_get_introducers(_): + """Return a dictionary of all introducers and their furls. + + The ones added to the storage node running on this FreedomBox. + """ + with open(introducers_file, 'r') as intro_conf: + conf = ruamel.yaml.round_trip_load(intro_conf) + + introducers = [] + for pet_name in conf['introducers'].keys(): + introducers.append((pet_name, conf['introducers'][pet_name]['furl'])) + + print(json.dumps(introducers)) + + +def subcommand_get_local_introducer(_): + """Return the name and furl of the introducer created on this FreedomBox + """ + with open(introducer_furl_file, 'r') as furl_file: + furl = furl_file.read().rstrip() + + print(json.dumps((introducer_name, furl))) + + +def subcommand_enable(_): + """Enable web configuration and reload.""" + action_utils.service_enable('tahoe-lafs') + action_utils.webserver_enable('tahoe-plinth') + + +def subcommand_disable(_): + """Disable web configuration and reload.""" + action_utils.webserver_disable('tahoe-plinth') + action_utils.service_disable('tahoe-lafs') + + +def restart_storage_node(): + """Called after exiting context of editing introducers file.""" + try: + subprocess.run(['tahoe', 'restart', 'storage_node'], check=True) + except subprocess.CalledProcessError as err: + print('Failed to restart storage_node with new configuration: %s', err) + + +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/apache2/conf-available/tahoe-plinth.conf b/data/etc/apache2/conf-available/tahoe-plinth.conf new file mode 100644 index 000000000..80e5c5fdd --- /dev/null +++ b/data/etc/apache2/conf-available/tahoe-plinth.conf @@ -0,0 +1,14 @@ +# Tahoe-LAFS Storage Node web interface + +Listen 5678 + +# XXX: SSL is not configured? +# TODO: Use subdomain? + + + Include includes/freedombox-auth-ldap.conf + Require ldap-group cn=admin,ou=groups,dc=thisbox + + ProxyPass http://localhost:1234/ + + diff --git a/data/etc/plinth/modules-enabled/tahoe b/data/etc/plinth/modules-enabled/tahoe new file mode 100644 index 000000000..89041b3fc --- /dev/null +++ b/data/etc/plinth/modules-enabled/tahoe @@ -0,0 +1 @@ +plinth.modules.tahoe diff --git a/data/usr/lib/firewalld/services/tahoe-plinth.xml b/data/usr/lib/firewalld/services/tahoe-plinth.xml new file mode 100644 index 000000000..e745d9636 --- /dev/null +++ b/data/usr/lib/firewalld/services/tahoe-plinth.xml @@ -0,0 +1,7 @@ + + + Tahoe-LAFS + Tahoe-LAFS is a distributed file storage system + + + diff --git a/plinth/actions.py b/plinth/actions.py index 7043c69cb..01de248f9 100644 --- a/plinth/actions.py +++ b/plinth/actions.py @@ -98,7 +98,6 @@ import subprocess from plinth import cfg from plinth.errors import ActionError - LOGGER = logging.getLogger(__name__) @@ -118,7 +117,17 @@ def superuser_run(action, options=None, input=None, async=False): return _run(action, options, input, async, True) -def _run(action, options=None, input=None, async=False, run_as_root=False): +def run_as_user(action, options=None, input=None, async=False, + become_user=None): + """Run a command as a different user. + + If become_user is None, run as current user. + """ + return _run(action, options, input, async, False, become_user) + + +def _run(action, options=None, input=None, async=False, run_as_root=False, + become_user=None): """Safely run a specific action as a normal user or root. Actions are pulled from the actions directory. @@ -159,6 +168,8 @@ def _run(action, options=None, input=None, async=False, run_as_root=False): # Contract 1: commands can run via sudo. if run_as_root: cmd = ['sudo', '-n'] + cmd + elif become_user: + cmd = ['sudo', '-n', '-u', become_user] + cmd LOGGER.info('Executing command - %s', cmd) diff --git a/plinth/forms.py b/plinth/forms.py index b9aca6c7b..cbf7fe63e 100644 --- a/plinth/forms.py +++ b/plinth/forms.py @@ -22,9 +22,26 @@ Common forms for use by modules. from django import forms from django.utils.translation import ugettext_lazy as _ +from plinth import utils + class ServiceForm(forms.Form): """Generic configuration form for a service.""" is_enabled = forms.BooleanField( label=_('Enable application'), required=False) + + +class DomainSelectionForm(forms.Form): + """Form for selecting a domain name to be used for + distributed federated applications + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['domain_name'].choices = utils.get_domain_names() + + domain_name = forms.ChoiceField( + label=_('Select the domain name to be used for this application'), + choices=[] + ) diff --git a/plinth/modules/diaspora/__init__.py b/plinth/modules/diaspora/__init__.py index 2658ea80e..cdd6626df 100644 --- a/plinth/modules/diaspora/__init__.py +++ b/plinth/modules/diaspora/__init__.py @@ -18,7 +18,6 @@ import os from django.utils.translation import ugettext_lazy as _ -from plinth.modules import names from plinth.utils import format_lazy from plinth import actions, action_utils, frontpage, \ service as service_module @@ -66,7 +65,7 @@ description = [ 'diaspora.{host} path on the ' 'web server.'.format(host=get_configured_domain_name()) if is_setup() else 'Please register a domain name for your FreedomBox to be able to' - ' federate with other diaspora* pods.') + ' federate with other diaspora* pods.') ] @@ -112,19 +111,6 @@ def setup(helper, old_version=None): helper.call('post', add_shortcut) -def get_domain_names(): - """Return the domain name(s)""" - results = [] - - for domain_type, domains in names.domains.items(): - if domain_type == 'hiddenservice': - continue - for domain in domains: - results.append((domain, domain)) - - return results - - def add_shortcut(): """Add shortcut to diaspora on the Plinth homepage""" if is_setup(): diff --git a/plinth/modules/diaspora/views.py b/plinth/modules/diaspora/views.py index edd61f7d7..157521af7 100644 --- a/plinth/modules/diaspora/views.py +++ b/plinth/modules/diaspora/views.py @@ -25,6 +25,7 @@ from django.views.generic import FormView from plinth import actions from plinth.modules import diaspora from plinth.modules.diaspora.forms import DiasporaForm +from plinth.utils import get_domain_names from plinth.views import ServiceView @@ -48,7 +49,7 @@ class DiasporaSetupView(FormView): context = super().get_context_data(**kwargs) context['description'] = self.description context['title'] = self.title - context['domain_names'] = diaspora.get_domain_names() + context['domain_names'] = get_domain_names() return context diff --git a/plinth/modules/matrixsynapse/__init__.py b/plinth/modules/matrixsynapse/__init__.py index af515b01b..0b4b50d0b 100644 --- a/plinth/modules/matrixsynapse/__init__.py +++ b/plinth/modules/matrixsynapse/__init__.py @@ -31,8 +31,6 @@ from plinth import actions from plinth import frontpage from plinth import service as service_module from plinth.menu import main_menu -from plinth.modules import names - version = 1 @@ -139,20 +137,6 @@ def diagnose(): return results -def get_domain_names(): - """Return the domain name(s).""" - results = [] - - for domain_type, domains in names.domains.items(): - if domain_type == 'hiddenservice': - continue - - for domain in domains: - results.append((domain, domain)) - - return results - - def get_configured_domain_name(): """Return the currently configured domain name.""" if not is_setup(): diff --git a/plinth/modules/matrixsynapse/forms.py b/plinth/modules/matrixsynapse/forms.py index 18872c524..48918065d 100644 --- a/plinth/modules/matrixsynapse/forms.py +++ b/plinth/modules/matrixsynapse/forms.py @@ -22,7 +22,7 @@ Forms for configuring matrix-synapse. from django import forms from django.utils.translation import ugettext_lazy as _ -from plinth.modules import matrixsynapse +from plinth.utils import get_domain_names class MatrixSynapseForm(forms.Form): @@ -35,4 +35,4 @@ class MatrixSynapseForm(forms.Form): def __init__(self, *args, **kwargs): """Initialize the form object.""" super().__init__(*args, **kwargs) - self.fields['domain_name'].choices = matrixsynapse.get_domain_names() + self.fields['domain_name'].choices = get_domain_names() diff --git a/plinth/modules/matrixsynapse/views.py b/plinth/modules/matrixsynapse/views.py index 9ad5363f9..f670c50d3 100644 --- a/plinth/modules/matrixsynapse/views.py +++ b/plinth/modules/matrixsynapse/views.py @@ -27,6 +27,7 @@ from plinth import actions from plinth import views from plinth.modules import matrixsynapse from plinth.modules.matrixsynapse.forms import MatrixSynapseForm +from plinth.utils import get_domain_names class SetupView(FormView): @@ -49,7 +50,7 @@ class SetupView(FormView): context['title'] = matrixsynapse.title context['description'] = matrixsynapse.description - context['domain_names'] = matrixsynapse.get_domain_names() + context['domain_names'] = get_domain_names() return context diff --git a/plinth/modules/tahoe/__init__.py b/plinth/modules/tahoe/__init__.py new file mode 100644 index 000000000..c3f16bf5b --- /dev/null +++ b/plinth/modules/tahoe/__init__.py @@ -0,0 +1,218 @@ +# +# This file is part of Plinth. +# +# 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 . +# +""" +Plinth module to configure Tahoe-LAFS. +""" + +import json +import os + +from django.utils.translation import ugettext_lazy as _ + +from plinth import action_utils +from plinth import actions +from plinth import cfg +from plinth import frontpage +from plinth import service as service_module +from plinth.menu import main_menu +from plinth.utils import format_lazy + +from .errors import TahoeConfigurationError + +version = 1 + +managed_services = ['tahoe-lafs'] + +managed_packages = ['tahoe-lafs'] + +title = _('Distributed File Storage (Tahoe-LAFS)') + +service = None + +tahoe_home = '/var/lib/tahoe-lafs' +introducer_name = 'introducer' +storage_node_name = 'storage_node' +domain_name_file = os.path.join(tahoe_home, 'domain_name') +introducers_file = os.path.join( + tahoe_home, '{}/private/introducers.yaml'.format(storage_node_name)) +introducer_furl_file = os.path.join( + tahoe_home, '{0}/private/{0}.furl'.format(introducer_name)) + + +def is_setup(): + """Check whether Tahoe-LAFS is setup""" + return os.path.exists(domain_name_file) + + +def get_configured_domain_name(): + """Extract and return the domain name from the domain name file. + Throws TahoeConfigurationError if the domain name file is not found. + """ + if not os.path.exists(domain_name_file): + raise TahoeConfigurationError + else: + with open(domain_name_file) as dnf: + return dnf.read().rstrip() + + +description = [ + _('Tahoe-LAFS is a decentralized secure file storage system. ' + 'It uses provider independent security to store files over a ' + 'distributed network of storage nodes. Even if some of the nodes fail, ' + 'your files can be retrieved from the remaining nodes.'), + format_lazy( + _('This {box_name} hosts a storage node and an introducer by default. ' + 'Additional introducers can be added, which will introduce this ' + 'node to the other storage nodes.'), + box_name=_(cfg.box_name)), +] + + +def init(): + """Intialize the module.""" + menu = main_menu.get('apps') + menu.add_urlname(title, 'glyphicon-hdd', 'tahoe:index') + + global service + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() != 'needs-setup' and is_setup(): + service = service_module.Service( + managed_services[0], + title, + ports=['tahoe-plinth'], + is_external=True, + is_enabled=is_enabled, + enable=enable, + disable=disable, + is_running=is_running) + + if is_enabled(): + add_shortcut() + + +def setup(helper, old_version=None): + """Install and configure the module.""" + helper.install(managed_packages) + + +def post_setup(configured_domain_name): + """Actions to be performed after installing tahoe-lafs package.""" + actions.superuser_run('tahoe-lafs', + ['setup', '--domain-name', configured_domain_name]) + actions.superuser_run('tahoe-lafs', ['enable']) + actions.run_as_user('tahoe-lafs', ['create-introducer'], + become_user='tahoe-lafs') + actions.run_as_user('tahoe-lafs', ['create-storage-node'], + become_user='tahoe-lafs') + actions.superuser_run('tahoe-lafs', ['autostart']) + + global service + if service is None: + service = service_module.Service( + managed_services[0], + title, + ports=['tahoe-plinth'], + is_external=True, + is_enabled=is_enabled, + enable=enable, + disable=disable, + is_running=is_running) + service.notify_enabled(None, True) + add_shortcut() + + +def add_shortcut(): + """Helper method to add a shortcut to the front page.""" + # BUG: Current logo appears squashed on front page. + frontpage.add_shortcut( + 'tahoe-lafs', title, + url='https://{}:5678'.format(get_configured_domain_name()), + login_required=True) + + +def is_running(): + """Return whether the service is running.""" + return action_utils.service_is_running(managed_services[0]) + + +def is_enabled(): + """Return whether the module is enabled.""" + return (action_utils.service_is_enabled(managed_services[0]) and + action_utils.webserver_is_enabled('tahoe-plinth')) + + +def enable(): + """Enable the module.""" + actions.superuser_run('tahoe-lafs', ['enable']) + add_shortcut() + + +def disable(): + """Enable the module.""" + actions.superuser_run('tahoe-lafs', ['disable']) + frontpage.remove_shortcut('tahoe-lafs') + + +def diagnose(): + """Run diagnostics and return the results.""" + return [action_utils.diagnose_url( + 'http://localhost:5678', kind='4', check_certificate=False), + action_utils.diagnose_url( + 'http://localhost:5678', kind='6', check_certificate=False), + action_utils.diagnose_url( + 'http://{}:5678'.format(get_configured_domain_name()), + kind='4', + check_certificate=False)] + + +def add_introducer(introducer): + """Add an introducer to the storage node's list of introducers. + Param introducer must be a tuple of (pet_name, furl) + """ + actions.run_as_user('tahoe-lafs', + ['add-introducer', + "--introducer", + ",".join(introducer)], + become_user='tahoe-lafs') + + +def remove_introducer(pet_name): + """Rename the introducer entry in the introducers.yaml file specified by + the param pet_name. + """ + actions.run_as_user('tahoe-lafs', + ['remove-introducer', '--pet-name', pet_name], + become_user='tahoe-lafs') + + +def get_introducers(): + """Return a dictionary of all introducers and their furls added to the + storage node running on this FreedomBox. + """ + introducers = actions.run_as_user('tahoe-lafs', ['get-introducers'], + become_user='tahoe-lafs') + + return json.loads(introducers) + + +def get_local_introducer(): + """Return the name and furl of the introducer created on this FreedomBox. + """ + introducer = actions.run_as_user('tahoe-lafs', ['get-local-introducer'], + become_user='tahoe-lafs') + + return json.loads(introducer) diff --git a/plinth/modules/tahoe/errors.py b/plinth/modules/tahoe/errors.py new file mode 100644 index 000000000..aaa5896e7 --- /dev/null +++ b/plinth/modules/tahoe/errors.py @@ -0,0 +1,27 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +Errors for Tahoe-LAFS module +""" + +from plinth.errors import PlinthError + + +class TahoeConfigurationError(PlinthError): + """Tahoe-LAFS has not been configured for domain name.""" + pass diff --git a/plinth/modules/tahoe/templates/tahoe-post-setup.html b/plinth/modules/tahoe/templates/tahoe-post-setup.html new file mode 100644 index 000000000..ebac240cd --- /dev/null +++ b/plinth/modules/tahoe/templates/tahoe-post-setup.html @@ -0,0 +1,110 @@ +{% extends "service.html" %} +{% comment %} +# +# This file is part of Plinth. +# +# 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 %} +{% load bootstrap %} + +{% block description %} + +{% for paragraph in description %} +

{{ paragraph|safe }}

+{% endfor %} + +

+ {% url 'config:index' as index_url %} + {% blocktrans trimmed with domain_name=domain_name %} + The Tahoe-LAFS server domain is set to {{ domain_name }}. + Changing the FreedomBox domain name needs a reinstall of + Tahoe-LAFS and you WILL LOSE DATA. You can access Tahoe-LAFS at + https://{{domain_name}}:5678. + {% endblocktrans %} +

+{% endblock %} + +{% block configuration %} +

{% trans "Configuration" %}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+
+ +

{% trans "Local introducer" %}

+ + + + + + + + + + + +
{% trans "Pet Name" %} furl
{{ local_introducer.0 }} {{ local_introducer.1 }}
+ +
+ {% csrf_token %} +

{% trans "Add new introducer" %}

+ +
+ + +
+
+ + +
+ +
+ +
+ +

{% trans "Connected introducers" %}

+ + + + + + + + {% for introducer, furl in introducers %} + + + + + {% csrf_token %} + + + + {% endfor %} +
{% trans "Pet Name" %} furl
{{ introducer }} {{ furl }}
+ +{% endblock %} diff --git a/plinth/modules/tahoe/templates/tahoe-pre-setup.html b/plinth/modules/tahoe/templates/tahoe-pre-setup.html new file mode 100644 index 000000000..3bbcc61eb --- /dev/null +++ b/plinth/modules/tahoe/templates/tahoe-pre-setup.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% comment %} +# +# This file is part of Plinth. +# +# 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 %} + {% block pagetitle %} +

{{ title }}

+ {% endblock %} + + {% block description %} + {% for paragraph in description %} +

{{ paragraph|safe }}

+ {% endfor %} + {% endblock %} + +

+ {% url 'config:index' as index_url %} + {% if domain_names|length == 0 %} + No domain(s) are set. You can setup your domain on the system at + {% trans "Configure" %} page. + {% endif %} +

+ + {% block status %} + {% endblock %} + + {% block diagnostics %} + {% endblock %} + + {% block configuration %} + {% if domain_names|length > 0 %} +

{% trans Configuration %}

+
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ {% endif %} + {% endblock %} +{% endblock %} diff --git a/plinth/modules/tahoe/urls.py b/plinth/modules/tahoe/urls.py new file mode 100644 index 000000000..754e9e8f6 --- /dev/null +++ b/plinth/modules/tahoe/urls.py @@ -0,0 +1,37 @@ +# +# This file is part of Plinth. +# +# 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 Tahoe-LAFS module. +""" + +from . import views + +from django.conf.urls import url + +from .views import TahoeSetupView, TahoeServiceView + + +urlpatterns = [ + url(r'^apps/tahoe-lafs/setup$', TahoeSetupView.as_view(), + name='setup'), + url(r'^apps/tahoe-lafs/add_introducer$', views.add_introducer, + name="add-introducer"), + url(r'^apps/tahoe-lafs/remove_introducer/(?P[0-9a-zA-Z_]+)$', + views.remove_introducer, name="remove-introducer"), + url(r'^apps/tahoe-lafs/$', TahoeServiceView.as_view(), + name='index') +] diff --git a/plinth/modules/tahoe/views.py b/plinth/modules/tahoe/views.py new file mode 100644 index 000000000..b0aba6ff5 --- /dev/null +++ b/plinth/modules/tahoe/views.py @@ -0,0 +1,83 @@ +# +# This file is part of Plinth. +# +# 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 Tahoe-LAFS module +""" +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.views.generic import FormView + +from plinth.forms import DomainSelectionForm +from plinth.modules import tahoe +from plinth.utils import get_domain_names +from plinth.views import ServiceView + + +class TahoeSetupView(FormView): + """Show tahoe-lafs setup page.""" + template_name = 'tahoe-pre-setup.html' + form_class = DomainSelectionForm + description = tahoe.description + title = tahoe.title + success_url = reverse_lazy('tahoe:index') + + def form_valid(self, form): + domain_name = form.cleaned_data['domain_name'] + tahoe.post_setup(domain_name) + return super().form_valid(form) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(**kwargs) + context['description'] = self.description + context['title'] = self.title + context['domain_names'] = get_domain_names() + + return context + + +class TahoeServiceView(ServiceView): + """Show tahoe-lafs service page.""" + service_id = tahoe.managed_services[0] + template_name = 'tahoe-post-setup.html' + description = tahoe.description + diagnostics_module_name = 'tahoe' + + def dispatch(self, request, *args, **kwargs): + if not tahoe.is_setup(): + return redirect('tahoe:setup') + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(**kwargs) + context['domain_name'] = tahoe.get_configured_domain_name() + context['introducers'] = tahoe.get_introducers() + context['local_introducer'] = tahoe.get_local_introducer() + + return context + + +def add_introducer(request): + if request.method == 'POST': + tahoe.add_introducer((request.POST['pet_name'], request.POST['furl'])) + return redirect('tahoe:index') + + +def remove_introducer(request, introducer): + if request.method == 'POST': + tahoe.remove_introducer(introducer) + return redirect('tahoe:index') diff --git a/plinth/tests/test_utils.py b/plinth/tests/test_utils.py index 514126805..cf980935b 100644 --- a/plinth/tests/test_utils.py +++ b/plinth/tests/test_utils.py @@ -19,11 +19,14 @@ Test module for Plinth's utilities. """ +import tempfile +from unittest import TestCase from unittest.mock import Mock, MagicMock -from django.test import TestCase +import ruamel.yaml from django.test.client import RequestFactory +from plinth.utils import YAMLFile from plinth.utils import is_user_admin @@ -81,3 +84,44 @@ class TestIsAdminUser(TestCase): mock.assert_called_once_with() session_mock.__getitem__.assert_called_once_with( 'cache_user_is_admin') + + +class TestYAMLFileUtil(TestCase): + """Check updating YAML files""" + + kv_pair = {'key': 'value'} + + def test_update_empty_yaml_file(self): + """ + Update an empty YAML file with content. + """ + fp = tempfile.NamedTemporaryFile() + conf = {'property1': self.kv_pair} + + with YAMLFile(fp.name) as file_conf: + for key in conf.keys(): + file_conf[key] = conf[key] + + with open(fp.name, 'r') as retrieved_conf: + assert retrieved_conf.read() == ruamel.yaml.round_trip_dump(conf) + + def test_update_non_empty_yaml_file(self): + """ + Update a non-empty YAML file with modifications + """ + fp = tempfile.NamedTemporaryFile() + + with open(fp.name, 'w') as conf_file: + conf_file.write( + ruamel.yaml.round_trip_dump({ + 'property1': self.kv_pair + })) + + with YAMLFile(fp.name) as file_conf: + file_conf['property2'] = self.kv_pair + + with open(fp.name, 'r') as retrieved_conf: + file_conf = ruamel.yaml.round_trip_load(retrieved_conf) + print(file_conf) + assert file_conf == {'property1': self.kv_pair, + 'property2': self.kv_pair} diff --git a/plinth/utils.py b/plinth/utils.py index e360983b6..8c14755c1 100644 --- a/plinth/utils.py +++ b/plinth/utils.py @@ -20,7 +20,10 @@ Miscellaneous utility methods. """ import importlib +import os +import ruamel.yaml from django.utils.functional import lazy +from plinth.modules import names def import_from_gi(library, version): @@ -63,3 +66,49 @@ def is_user_admin(request, cached=False): user_is_admin = request.user.groups.filter(name='admin').exists() request.session['cache_user_is_admin'] = user_is_admin return user_is_admin + + +def get_domain_names(): + """Return the domain name(s)""" + domain_names = [] + + for domain_type, domains in names.domains.items(): + if domain_type == 'hiddenservice': + continue + for domain in domains: + domain_names.append((domain, domain)) + + return domain_names + + +class YAMLFile(object): + """A context management class for updating YAML files""" + def __init__(self, yaml_file, post_exit=None): + """Return a context object for the YAML file. + + Parameters: + yaml_file - the YAML file to update. + post_exit - a function that will be called after updating the YAML file. + """ + self.yaml_file = yaml_file + self.post_exit = post_exit + self.conf = None + + def __enter__(self): + with open(self.yaml_file, 'r') as intro_conf: + if not self.is_file_empty(): + self.conf = ruamel.yaml.round_trip_load(intro_conf) + else: + self.conf = {} + + return self.conf + + def __exit__(self, typ, value, traceback): + with open(self.yaml_file, 'w') as intro_conf: + ruamel.yaml.round_trip_dump(self.conf, intro_conf) + + if self.post_exit: + self.post_exit() + + def is_file_empty(self): + return os.stat(self.yaml_file).st_size == 0 diff --git a/static/themes/default/icons/tahoe-lafs.png b/static/themes/default/icons/tahoe-lafs.png new file mode 100644 index 000000000..cb25e69df Binary files /dev/null and b/static/themes/default/icons/tahoe-lafs.png differ