diff --git a/actions/sharing b/actions/sharing old mode 100644 new mode 100755 index a3dd88669..baac364a7 --- a/actions/sharing +++ b/actions/sharing @@ -1,7 +1,6 @@ #!/usr/bin/python3 -# -*- mode: python -*- # -# This file is part of Plinth. +# 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 @@ -16,77 +15,177 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ -Configuration helper for the sharing module +Configuration helper for the sharing app. """ import argparse +import json import os +import pathlib +import re import augeas +from plinth import action_utils + +APACHE_CONFIGURATION = '/etc/apache2/conf-available/sharing-freedombox.conf' + def parse_arguments(): parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(help='Sub command') + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') - subparsers.add_parser('add', help='Add a new share') - subparsers.add_parser('remove', help='Remove an existing share') - subparsers.add_parser('list', help='Remove an existing share') + subparsers.add_parser('list', help='List all existing shares') + + add_parser = subparsers.add_parser('add', help='Add a new share') + add_parser.add_argument('--name', required=True, help='Name of the share') + add_parser.add_argument('--path', required=True, help='Disk path to share') + add_parser.add_argument('--groups', nargs='*', + help='List of groups that can access the share') + + remove_parser = subparsers.add_parser('remove', + help='Remove an existing share') + remove_parser.add_argument('--name', required=True, + help='Name of the share to remove') subparsers.required = True return parser.parse_args() -def load_augeas(conf_file): - aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + - augeas.Augeas.NO_MODL_AUTOLOAD) +def load_augeas(): + """Initialize augeas for this app's configuration file.""" + aug = augeas.Augeas( + flags=augeas.Augeas.NO_LOAD + augeas.Augeas.NO_MODL_AUTOLOAD) aug.set('/augeas/load/Httpd/lens', 'Httpd.lns') - aug.set('/augeas/load/Httpd/incl[last() + 1]', conf_file) + aug.set('/augeas/load/Httpd/incl[last() + 1]', APACHE_CONFIGURATION) aug.load() + aug.defvar('conf', '/files' + APACHE_CONFIGURATION) + return aug -# TODO: Handle the error case scenarios def subcommand_add(arguments): - share_url = arguments.url - share_path = arguments.path - share_user = arguments.user + """Add a share to Apache configuration.""" + name = arguments.name + path = arguments.path + groups = arguments.groups + url = '/share/' + name - aug = load_augeas('/etc/apache2/sites-available/sharing.conf') - aug.defvar('conf', '/files/etc/apache2/sites-available/sharing.conf') + if not os.path.exists(APACHE_CONFIGURATION): + pathlib.Path(APACHE_CONFIGURATION).touch() + + aug = load_augeas() + shares = _list(aug) + if any([share for share in shares if share['name'] == name]): + raise Exception('Share already present') + + aug.set('$conf/directive[last() + 1]', 'Alias') + aug.set('$conf/directive[last()]/arg[1]', url) + aug.set('$conf/directive[last()]/arg[2]', path) + + aug.set('$conf/Location[last() + 1]/arg', url) + + aug.set('$conf/Location[last()]/directive[last() + 1]', 'Include') + aug.set('$conf/Location[last()]/directive[last()]/arg', + 'includes/freedombox-sharing.conf') + aug.set('$conf/Location[last()]/directive[last() + 1]', 'Include') + aug.set('$conf/Location[last()]/directive[last()]/arg', + 'includes/freedombox-single-sign-on.conf') + + aug.set('$conf/Location[last()]/IfModule/arg', 'mod_auth_pubtkt.c') + aug.set('$conf/Location[last()]/IfModule/directive[1]', 'TKTAuthToken') + for group_name in groups: + aug.set('$conf/Location[last()]/IfModule/directive[1]/arg[last() + 1]', + group_name) + + aug.save() + + with action_utils.WebserverChange() as webserver_change: + webserver_change.enable('sharing-freedombox') + + +def subcommand_remove(arguments): + """Remove a share from Apache configuration.""" + url_to_remove = '/share/' + arguments.name + + aug = load_augeas() + + for directive in aug.match('$conf/directive'): + if aug.get(directive) != 'Alias': + continue + + url = aug.get(directive + '/arg[1]') + if url == url_to_remove: + aug.remove(directive) + + for location in aug.match('$conf/Location'): + url = aug.get(location + '/arg') + if url == url_to_remove: + aug.remove(location) + + aug.save() + + with action_utils.WebserverChange() as webserver_change: + webserver_change.enable('sharing-freedombox') + + +def _get_name_from_url(url): + """Return the name of the share given the URL for it.""" + matches = re.match(r'/share/([a-z0-9\-]*)', url) + if not matches: + raise ValueError + + return matches[1] + + +def _list(aug=None): + """List all Apache configuration shares.""" + if not aug: + aug = load_augeas() + + shares = [] + + for match in aug.match('$conf/directive'): + if aug.get(match) != 'Alias': + continue + + url = aug.get(match + '/arg[1]') + path = aug.get(match + '/arg[2]') - if os.path.exists(share_path): try: - aug.set('$conf/directive[last() + 1]', 'Alias') - aug.set('$conf/directive[last()]/arg[last() + 1]', share_url) - aug.set('$conf/directive[last()]/arg[last() + 1]', share_path) + name = _get_name_from_url(url) + shares.append({ + 'name': name, + 'path': path, + 'url': '/share/' + name + }) + except ValueError: + continue - aug.set('$conf/Directory[last() + 1]') - aug.set('$conf/Directory[last()]/arg', share_path) - aug.set('$conf/Directory[last()]/directive[last() + 1]', 'Include') - aug.set('$conf/Directory[last()]/directive[last()]/arg', 'includes/freedombox-sharing.conf') - aug.set('$conf/Directory[last()]/directive[last() + 1]', 'Require') - aug.set('$conf/Directory[last()]/directive[last()]/arg', share_user) - except Exception: - pass + for location in aug.match('$conf/Location'): + url = aug.get(location + '/arg') + + try: + name = _get_name_from_url(url) + except ValueError: + continue + + groups = [] + for group in aug.match(location + '//directive["TKTAuthToken"]/arg'): + groups.append(aug.get(group)) + + for share in shares: + if share['name'] == name: + share['groups'] = groups + + return shares -def subcommand_list(): - aug = load_augeas('/etc/apache2/sites-available/sharing.conf') - aug.defvar('conf', '/files/etc/apache2/sites-available/sharing.conf') - - path = '/files/etc/apache2/conf-available/sharing.conf/Directory' - list_of_shares = [] - for match in aug.match(path): - path = aug.get(match + '/arg') - for directive_name in aug.match(match + '/directive'): - if directive_name == 'Require': - user = aug.get(directive_name + '/arg') - list_of_shares.append(dict(path=path, user_group=user)) - return list_of_shares +def subcommand_list(_): + """List all Apache configuration shares and print as JSON.""" + print(json.dumps({'shares': _list()})) def main(): diff --git a/data/etc/apache2/conf-available/sharing.conf b/data/etc/apache2/conf-available/sharing.conf deleted file mode 100644 index e69de29bb..000000000 diff --git a/data/etc/apache2/includes/freedombox-sharing.conf b/data/etc/apache2/includes/freedombox-sharing.conf index 388435715..c8d6f5b25 100644 --- a/data/etc/apache2/includes/freedombox-sharing.conf +++ b/data/etc/apache2/includes/freedombox-sharing.conf @@ -1 +1,2 @@ -Options -FollowSymLinks \ No newline at end of file +Options +Indexes -FollowSymLinks -ExecCGI -Includes -IncludesNOEXEC +AllowOverride None diff --git a/plinth/modules/sharing/__init__.py b/plinth/modules/sharing/__init__.py index 597b24c2b..ed7527a32 100644 --- a/plinth/modules/sharing/__init__.py +++ b/plinth/modules/sharing/__init__.py @@ -1,5 +1,5 @@ # -# This file is part of Plinth. +# 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 @@ -14,94 +14,46 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ -Plinth module to configure sharing. +FreedomBox app to configure sharing. """ -import os -from django import forms -from django.template.response import TemplateResponse -from django.urls import reverse_lazy -from django.utils.translation import ugettext_lazy as _, ugettext_lazy +import json + +from django.utils.translation import ugettext_lazy as _ from plinth import actions from plinth.menu import main_menu -from plinth.modules.users import groups version = 1 name = _('Sharing') -short_description = _('File Sharing') - description = [ - _('Sharing allows you to share your content over web with a group of ' - 'users. Add the content you would like to share in the sharing app.'), - - _('Sharing app will be available from ' - '/sharing path on the web server.'), + _('Sharing allows you to share your content over web with chosen groups' + 'of users. Add the content you would like to share in the sharing app.'), ] -subsubmenu = [{'url': reverse_lazy('sharing:about'), - 'text': ugettext_lazy('About')}, - {'url': reverse_lazy('sharing:add_share'), - 'text': ugettext_lazy('Add share')}] - def init(): """Initialize the module.""" menu = main_menu.get('apps') - menu.add_urlname(name, 'glyphicon-share', 'sharing:about') + menu.add_urlname(name, 'glyphicon-share', 'sharing:index') -def index(request): - return TemplateResponse(request, 'about.html', - {'title': name, - 'description': description, - 'subsubmenu': subsubmenu}) +def list_shares(): + """Return a list of shares.""" + output = actions.superuser_run('sharing', ['list']) + return json.loads(output)['shares'] -# TODO: handle the error case -def add_path_to_share(url, path, user_group): - if os.path.exists(path): - actions.superuser_run('sharing', options=['add', url, path, user_group]) - else: - pass +def add_share(name, path, groups): + """Add a new share by called the action script.""" + actions.superuser_run( + 'sharing', + ['add', '--name', name, '--path', path, '--groups'] + groups) -def share(request): - if request.method == 'POST': - form = AddShareForm(request.POST) - - if form.is_valid(): - path = form.cleaned_data['share_path'] - user_group = form.cleaned_data['user_group'] - share_url = 'share_' + path.split("/")[len(path.split("/")) - 1] - add_path_to_share(share_url, path, user_group) - - form = AddShareForm() - - else: - form = AddShareForm() - - return TemplateResponse(request, 'share.html', - {'title': name, - 'subsubmenu': subsubmenu, - 'form': form}) - - -class AddShareForm(forms.Form): - share_path = forms.CharField( - label=_('Add path'), - help_text=_('Add the path to the folder you want to share')) - - user_group = forms.ChoiceField( - required=False, - choices=groups, - label=_('User-group'), - initial=None) - - def __init__(self, *args, **kwargs): - super(forms.Form, self).__init__(*args, **kwargs) - return +def remove_share(name): + """Remove a share by calling the action script.""" + actions.superuser_run('sharing', ['remove', '--name', name]) diff --git a/plinth/modules/sharing/forms.py b/plinth/modules/sharing/forms.py new file mode 100644 index 000000000..e36aa371c --- /dev/null +++ b/plinth/modules/sharing/forms.py @@ -0,0 +1,64 @@ +# +# 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 forms for sharing app. +""" + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from plinth.modules.users.forms import get_group_choices +from plinth.modules import sharing + + +class AddShareForm(forms.Form): + """Form to add a new share.""" + + name = forms.RegexField( + label=_('Name of the share'), strip=True, regex=r'^[a-z0-9]+$', + help_text=_( + 'A lowercase alpha-numeric string that uniquely identifies a ' + 'share. Example: media.')) + + path = forms.CharField( + label=_('Path to share'), strip=True, help_text=_( + 'Disk path to a folder on this server that you intend to share.')) + + groups = forms.MultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + label=_('User groups who can read the files in the share'), + help_text=_( + 'Users who have these permissions will also be able to read the ' + 'files in the share.')) + + def __init__(self, *args, **kwargs): + """Initialize the form with extra request argument.""" + super().__init__(*args, **kwargs) + self.fields['groups'].choices = get_group_choices() + + def clean_name(self): + """Check if the name is valid.""" + name = self.cleaned_data['name'] + if 'name' in self.initial and name == self.initial['name']: + return name + + if any((share for share in sharing.list_shares() + if name == share['name'])): + raise ValidationError(_('A share with this name already exists.')) + + return name diff --git a/plinth/modules/sharing/templates/about.html b/plinth/modules/sharing/templates/about.html deleted file mode 100644 index 91e4bff4d..000000000 --- a/plinth/modules/sharing/templates/about.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "simple_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 %} - -{% block configuration %} - -{% endblock %} diff --git a/plinth/modules/sharing/templates/sharing.html b/plinth/modules/sharing/templates/sharing.html new file mode 100644 index 000000000..c0264b7af --- /dev/null +++ b/plinth/modules/sharing/templates/sharing.html @@ -0,0 +1,83 @@ +{% 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 content %} + +

{{ title }}

+ + {% for paragraph in description %} +

{{ paragraph|safe }}

+ {% endfor %} + +

+ + {% trans 'Add new share' %} + +

+ + {% if not shares %} +

{% trans 'No shares currently configured.' %}

+ {% else %} + + + + + + + + + + + + + {% for share in shares %} + + + + + + + + {% endfor %} + +
{% trans "Share Name" %}{% trans "Disk Path" %}{% trans "Shared Over" %}{% trans "To Groups" %}
+ {% endif %} + +{% endblock %} diff --git a/plinth/modules/sharing/templates/share.html b/plinth/modules/sharing/templates/sharing_add_edit.html similarity index 64% rename from plinth/modules/sharing/templates/share.html rename to plinth/modules/sharing/templates/sharing_add_edit.html index ceb22ac11..d2b752c95 100644 --- a/plinth/modules/sharing/templates/share.html +++ b/plinth/modules/sharing/templates/sharing_add_edit.html @@ -1,7 +1,7 @@ -{% extends "simple_service.html" %} +{% extends "base.html" %} {% comment %} # -# This file is part of Plinth. +# 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 @@ -21,16 +21,23 @@ {% load bootstrap %} {% load i18n %} -{% block configuration %} +{% block content %} -
- {% csrf_token %} +

{{ title }}

- {{ form|bootstrap }} + + {% csrf_token %} - -
+ {{ form|bootstrap }} + + -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block page_js %} + +{% endblock %} diff --git a/plinth/modules/sharing/urls.py b/plinth/modules/sharing/urls.py index 4a85e3fe1..853f56490 100644 --- a/plinth/modules/sharing/urls.py +++ b/plinth/modules/sharing/urls.py @@ -1,5 +1,5 @@ # -# This file is part of Plinth. +# 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 @@ -14,16 +14,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ -URLs for the sharing module. +URLs for the sharing app. """ from django.conf.urls import url -from . import index, share +from .views import AddShareView, EditShareView, IndexView, remove urlpatterns = [ - url(r'^apps/sharing/$', index, name='about'), - url(r'^apps/sharing/add_share$', share, name='add_share'), -] \ No newline at end of file + url(r'^apps/sharing/$', IndexView.as_view(), name='index'), + url(r'^apps/sharing/add/$', AddShareView.as_view(), name='add'), + url(r'^apps/sharing/(?P[a-z0-9]+)/edit/$', EditShareView.as_view(), + name='edit'), + url(r'^apps/sharing/(?P[a-z0-9]+)/remove/$', remove, name='remove'), +] diff --git a/plinth/modules/sharing/views.py b/plinth/modules/sharing/views.py new file mode 100644 index 000000000..ee6efbc38 --- /dev/null +++ b/plinth/modules/sharing/views.py @@ -0,0 +1,109 @@ +# +# 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 sharing 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_lazy as _ +from django.views.decorators.http import require_POST +from django.views.generic import FormView, TemplateView + +from plinth.modules import sharing + +from .forms import AddShareForm + + +class IndexView(TemplateView): + """View to show list of shares.""" + template_name = 'sharing.html' + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = sharing.name + context['description'] = sharing.description + context['shares'] = sharing.list_shares() + return context + + +class AddShareView(SuccessMessageMixin, FormView): + """View to add a new share.""" + form_class = AddShareForm + prefix = 'sharing' + template_name = 'sharing_add_edit.html' + success_url = reverse_lazy('sharing:index') + success_message = _('Share added.') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Add Share') + return context + + def form_valid(self, form): + """Add the share on valid form submission.""" + sharing.add_share(form.cleaned_data['name'], form.cleaned_data['path'], + form.cleaned_data['groups']) + return super().form_valid(form) + + +class EditShareView(SuccessMessageMixin, FormView): + """View to edit an existing share.""" + form_class = AddShareForm + prefix = 'sharing' + template_name = 'sharing_add_edit.html' + success_url = reverse_lazy('sharing:index') + success_message = _('Share edited.') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Edit Share') + return context + + def get_initial(self): + """Load information about share being edited.""" + try: + return [ + share for share in sharing.list_shares() + if share['name'] == self.kwargs['name'] + ][0] + except IndexError: + raise Http404 + + def form_valid(self, form): + """Add the share on valid form submission.""" + if form.initial != form.cleaned_data: + sharing.remove_share(form.initial['name']) + sharing.add_share(form.cleaned_data['name'], + form.cleaned_data['path'], + form.cleaned_data['groups']) + + return super().form_valid(form) + + +@require_POST +def remove(request, name): + """View to remove a share.""" + sharing.remove_share(name) + messages.success(request, _('Share deleted.')) + return redirect(reverse_lazy('sharing:index'))