diff --git a/actions/bepasty b/actions/bepasty new file mode 100755 index 000000000..5b0c810c8 --- /dev/null +++ b/actions/bepasty @@ -0,0 +1,213 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Configuration helper for bepasty. +""" + +import argparse +import json +import grp +import os +import pwd +import secrets +import shutil +import string +import subprocess + +from plinth import action_utils +from plinth.modules import bepasty + +DATA_DIR = '/var/lib/bepasty' + +CONF_FILE = '/etc/bepasty-freedombox.conf' + +CONF_CONTENTS = """ +SITENAME = '{}' +STORAGE_FILESYSTEM_DIRECTORY = '/var/lib/bepasty' +SECRET_KEY = '{}' +PERMISSIONS = {{ + '{}': 'admin,list,create,read,delete', # admin + '{}': 'list,create,read,delete', # editor + '{}': 'list,read', # viewer +}} +""" + +PASSWORD_LENGTH = 20 + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + setup = subparsers.add_parser( + 'setup', help='Perform post-installation operations for bepasty') + setup.add_argument('--domain-name', + help='The domain name that will be used by bepasty') + + subparsers.add_parser( + 'list-passwords', + help='Get a list of passwords, their permissions and comments') + + add_password = subparsers.add_parser( + 'add-password', help='Generate a password with given permissions') + add_password.add_argument( + '--permissions', nargs='*', + help='Any number of permissions from the set: {}'.format(', '.join( + bepasty.PERMISSIONS.keys()))) + add_password.add_argument( + '--comment', required=False, + help='A comment for the password and its permissions') + + remove_password = subparsers.add_parser( + 'remove-password', help='Remove a password and its permissions') + remove_password.add_argument('--password', required=True, + help='The password to be removed') + + subparsers.required = True + return parser.parse_args() + + +def subcommand_setup(arguments): + """Post installation actions for bepasty.""" + # Create bepasty group if needed. + try: + grp.getgrnam('bepasty') + except KeyError: + subprocess.run(['addgroup', '--system', 'bepasty'], check=True) + + # Create bepasty user is needed. + try: + pwd.getpwnam('bepasty') + except KeyError: + subprocess.run([ + 'adduser', '--system', '--ingroup', 'bepasty', '--home', + '/var/lib/bepasty', '--gecos', 'bepasty file sharing', 'bepasty' + ], check=True) + + # Create data directory if needed. + if not os.path.exists(DATA_DIR): + os.makedirs(DATA_DIR, mode=0o750) + shutil.chown(DATA_DIR, user='bepasty', group='bepasty') + + # Create configuration file if needed. + if not os.path.isfile(CONF_FILE): + # Generate secrets + secret_key = secrets.token_hex(64) + passwords = [] + for i in range(3): + passwords.append(_generate_password()) + + with open(CONF_FILE, 'w') as conf_file: + conf_file.write( + CONF_CONTENTS.format(arguments.domain_name, secret_key, + *passwords)) + + os.chmod(CONF_FILE, 0o640) + shutil.chown(CONF_FILE, user='bepasty', group='bepasty') + + +def subcommand_list_passwords(_): + """Get a list of passwords, their permissions and comments""" + with open(CONF_FILE, 'r') as conf_file: + lines = conf_file.readlines() + + passwords = [] + in_permissions = False + for line in lines: + if line.startswith('PERMISSIONS'): + in_permissions = True + elif in_permissions: + if line.startswith('}'): + in_permissions = False + else: + parts = line.split('#') + try: + comment = parts[1].strip() + except IndexError: + comment = '' + + parts = parts[0].split(':') + password = parts[0].replace("'", '').strip() + permissions = parts[1].replace( + "'", '').strip().rstrip(',').split(',') + passwords.append({ + 'password': password, + 'permissions': ', '.join(permissions), + 'comment': comment + }) + + print(json.dumps(passwords)) + + +def subcommand_add_password(arguments): + """Generate a password with given permissions""" + if arguments.permissions: + permissions = set(bepasty.PERMISSIONS.keys()).intersection( + arguments.permissions) + permissions = ','.join(permissions) + else: + permissions = '' + + password = _generate_password() + with open(CONF_FILE, 'r') as conf_file: + lines = conf_file.readlines() + + with open(CONF_FILE, 'w') as conf_file: + in_permissions = False + for line in lines: + if line.startswith('PERMISSIONS'): + in_permissions = True + elif in_permissions: + if line.startswith('}'): + in_permissions = False + conf_file.write(" '{}': '{}',".format( + password, permissions)) + if arguments.comment: + conf_file.write(' # {}'.format(arguments.comment)) + + conf_file.write('\n') + + conf_file.write(line) + + action_utils.service_try_restart('uwsgi') + + +def subcommand_remove_password(arguments): + """Remove a password and its permissions""" + with open(CONF_FILE, 'r') as conf_file: + lines = conf_file.readlines() + + with open(CONF_FILE, 'w') as conf_file: + in_permissions = False + for line in lines: + if line.startswith('PERMISSIONS'): + in_permissions = True + elif in_permissions: + if line.startswith('}'): + in_permissions = False + elif arguments.password in line: + continue + + conf_file.write(line) + + action_utils.service_try_restart('uwsgi') + + +def _generate_password(): + """Generate a random password""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for i in range(PASSWORD_LENGTH)) + + +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/debian/copyright b/debian/copyright index 1894d725a..64e4126f9 100644 --- a/debian/copyright +++ b/debian/copyright @@ -39,6 +39,11 @@ Copyright: Marie Van den Broeck (https://thenounproject.com/marie49/) Comment: https://thenounproject.com/icon/162372/ License: CC-BY-SA-3.0 +Files: static/themes/default/icons/bepasty.svg +Copyright: (c) 2014 by the Bepasty Team, see the AUTHORS file. +Comment: https://github.com/bepasty/bepasty-server/blob/master/src/bepasty/static/app/bepasty.svg +License: BSD-2-clause + Files: static/themes/default/icons/cockpit.svg Copyright: Cockpit Authors (https://github.com/cockpit-project/cockpit/blob/master/AUTHORS) Comment: https://github.com/cockpit-project/cockpit/blob/master/src/branding/default/logo.svg @@ -979,6 +984,27 @@ License: Apache-2.0 On Debian systems, the full text of the Apache Software License version 2 can be found in the file `/usr/share/common-licenses/Apache-2.0'. +License: BSD-2-clause + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + . + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + License: BSD-3-clause Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions diff --git a/plinth/modules/bepasty/__init__.py b/plinth/modules/bepasty/__init__.py new file mode 100644 index 000000000..bad653efd --- /dev/null +++ b/plinth/modules/bepasty/__init__.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for bepasty. +""" + +import json + +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth import app as app_module +from plinth import frontpage, menu +from plinth.modules.apache.components import Uwsgi, Webserver +from plinth.modules.firewall.components import Firewall + +from .manifest import backup, clients # noqa, pylint: disable=unused-import + +version = 1 + +managed_packages = ['bepasty', 'uwsgi', 'uwsgi-plugin-python3'] + +managed_services = ['uwsgi'] + +description = [ + _('bepasty is a web application that allows all types of files to be ' + 'uploaded and shared.'), + _('bepasty does not use usernames for login. It only uses passwords. For ' + 'each password, a set of permissions can be selected. Once you have ' + 'created a password, you can share it with the users who should have the' + ' associated permissions.'), + _('You can also create multiple passwords with the same set of privileges,' + ' and distribute them to different people or groups. This will allow ' + 'you to later revoke access for a single person or group, by removing ' + 'their password from the list.'), +] + +app = None + +PERMISSIONS = { + 'read': _('Read files (using their web address)'), + 'create': _('Create or upload files'), + 'list': _('List all files'), + 'delete': _('Delete files'), + 'admin': _('Admin (lock/unlock files)'), +} + + +class BepastyApp(app_module.App): + """FreedomBox app for bepasty.""" + + app_id = 'bepasty' + + def __init__(self): + """Create components for the app.""" + super().__init__() + + info = app_module.Info(self.app_id, version, name=_('bepasty'), + icon_filename='bepasty', + short_description=_('File Sharing'), + description=description, manual_page='bepasty', + clients=clients) + self.add(info) + + menu_item = menu.Menu('menu-bepasty', info.name, + info.short_description, info.icon_filename, + 'bepasty:index', parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-bepasty', info.name, + info.short_description, + info.icon_filename, '/bepasty', + clients=clients) + self.add(shortcut) + + firewall = Firewall('firewall-bepasty', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + uwsgi = Uwsgi('uwsgi-bepasty', 'bepasty-freedombox') + self.add(uwsgi) + + webserver = Webserver('webserver-bepasty', 'bepasty-freedombox') + self.add(webserver) + + +def setup(helper, old_version=None): + """Install and configure the module.""" + helper.install(managed_packages) + helper.call('post', actions.superuser_run, 'bepasty', + ['setup', '--domain-name', 'freedombox.local']) + helper.call('post', app.enable) + + +def list_passwords(): + """Get a list of passwords, their permissions and comments""" + output = actions.superuser_run('bepasty', ['list-passwords']) + return json.loads(output) + + +def add_password(permissions=None, comment=None): + """Generate a password with given permissions""" + command = ['add-password'] + if permissions: + command += ['--permissions'] + permissions + + if comment: + command += ['--comment', comment] + + actions.superuser_run('bepasty', command) + + +def remove_password(password): + """Remove a password and its permissions""" + actions.superuser_run('bepasty', + ['remove-password', '--password', password]) diff --git a/plinth/modules/bepasty/data/etc/apache2/conf-available/bepasty-freedombox.conf b/plinth/modules/bepasty/data/etc/apache2/conf-available/bepasty-freedombox.conf new file mode 100644 index 000000000..7ca23e624 --- /dev/null +++ b/plinth/modules/bepasty/data/etc/apache2/conf-available/bepasty-freedombox.conf @@ -0,0 +1,16 @@ +## +## On all sites, provide bepasty on a path: /bepasty +## + +# Redirect /bepasty to /bepasty/ + + + RewriteEngine On + RewriteCond %{REQUEST_URI} ^/bepasty$ + RewriteRule .* /bepasty/ [R=301,L] + + + + + ProxyPass unix:/run/uwsgi/app/bepasty-freedombox/socket|uwsgi://bepasty/ + diff --git a/plinth/modules/bepasty/data/etc/plinth/modules-enabled/bepasty b/plinth/modules/bepasty/data/etc/plinth/modules-enabled/bepasty new file mode 100644 index 000000000..3e53798b2 --- /dev/null +++ b/plinth/modules/bepasty/data/etc/plinth/modules-enabled/bepasty @@ -0,0 +1 @@ +plinth.modules.bepasty diff --git a/plinth/modules/bepasty/data/etc/uwsgi/apps-available/bepasty-freedombox.ini b/plinth/modules/bepasty/data/etc/uwsgi/apps-available/bepasty-freedombox.ini new file mode 100644 index 000000000..3195d2a06 --- /dev/null +++ b/plinth/modules/bepasty/data/etc/uwsgi/apps-available/bepasty-freedombox.ini @@ -0,0 +1,31 @@ +# Use packaged file after #966314 is done. + +[uwsgi] +# Who will run the code +uid = bepasty +gid = bepasty + +# disable logging for privacy +#disable-logging = true + +autoload = false + +# Number of workers (usually CPU count) +workers = 2 + +# The right granted on the created socket +chmod-socket = 666 + +# Plugin to use and interpretor config +single-interpreter = true +master = true +plugin = python3 +enable-threads = true +lazy-apps = true + +# Module to import +module = bepasty.wsgi +env = BEPASTY_CONFIG=/etc/bepasty-freedombox.conf + +pythonpath = /usr/lib/python3/dist-packages/ +buffer-size = 32768 diff --git a/plinth/modules/bepasty/forms.py b/plinth/modules/bepasty/forms.py new file mode 100644 index 000000000..9cfc150dc --- /dev/null +++ b/plinth/modules/bepasty/forms.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Django forms for bepasty app. +""" + +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from plinth.modules import bepasty + + +class AddPasswordForm(forms.Form): + """Form to add a new password.""" + + permissions = forms.MultipleChoiceField( + choices=bepasty.PERMISSIONS.items(), + widget=forms.CheckboxSelectMultiple, required=False, + label=_('Permissions'), help_text=_( + 'Users that log in with this password will have the selected ' + 'permissions.')) + + comment = forms.CharField( + label=_('Comment'), required=False, strip=True, help_text=_( + 'Any comment to help you remember the purpose of this password.')) diff --git a/plinth/modules/bepasty/manifest.py b/plinth/modules/bepasty/manifest.py new file mode 100644 index 000000000..1f267e1c6 --- /dev/null +++ b/plinth/modules/bepasty/manifest.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.utils.translation import ugettext_lazy as _ + +from plinth.clients import validate +from plinth.modules.backups.api import validate as validate_backup + +clients = validate([{ + 'name': _('bepasty'), + 'platforms': [{ + 'type': 'web', + 'url': '/bepasty' + }] +}]) + +backup = validate_backup({ + 'config': { + 'files': ['/etc/bepasty-freedombox.conf'] + }, + 'data': { + 'directories': ['/var/lib/bepasty'] + }, + 'services': ['uwsgi'], +}) diff --git a/plinth/modules/bepasty/templates/bepasty.html b/plinth/modules/bepasty/templates/bepasty.html new file mode 100644 index 000000000..5f3fd9ffb --- /dev/null +++ b/plinth/modules/bepasty/templates/bepasty.html @@ -0,0 +1,55 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block configuration %} + {{ block.super }} + +

{% trans "Manage Passwords" %}

+ +
+ + + {% trans 'Add password' %} + +
+ + {% if not passwords %} +

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

+ {% else %} + + + + + + + + + + + + {% for password in passwords %} + + + + + + + {% endfor %} + +
{% trans "Password" %}{% trans "Permissions" %}{% trans "Comment" %}
{{ password.password }}{{ password.permissions }}{{ password.comment }} +
+ {% csrf_token %} + +
+
+ {% endif %} + +{% endblock %} diff --git a/plinth/modules/bepasty/templates/bepasty_add.html b/plinth/modules/bepasty/templates/bepasty_add.html new file mode 100644 index 000000000..3b9f384c8 --- /dev/null +++ b/plinth/modules/bepasty/templates/bepasty_add.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} +{% load static %} + +{% block content %} + +

{{ title }}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ +{% endblock %} diff --git a/plinth/modules/bepasty/tests/__init__.py b/plinth/modules/bepasty/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/bepasty/tests/bepasty.feature b/plinth/modules/bepasty/tests/bepasty.feature new file mode 100644 index 000000000..e381c8c7a --- /dev/null +++ b/plinth/modules/bepasty/tests/bepasty.feature @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +@apps @bepasty +Feature: bepasty File Sharing + Run bepasty file upload and sharing app. + +Background: + Given I'm a logged in user + Given the bepasty application is installed + +Scenario: Enable bepasty application + Given the bepasty application is disabled + When I enable the bepasty application + Then the bepasty site should be available + +Scenario: Add password + Given the bepasty application is enabled + When I add a password + Then I should be able to login to bepasty with that password + +Scenario: Remove password + Given the bepasty application is enabled + When I remove all passwords + Then I should not be able to login to bepasty with that password + +@backups +Scenario: Backup and restore bepasty + Given the bepasty application is enabled + When I add a password + And I create a backup of the bepasty app data with name test_bepasty + And I remove all passwords + And I restore the bepasty app data backup with name test_bepasty + Then the bepasty site should be available + And I should be able to login to bepasty with that password + +Scenario: Disable bepasty application + Given the bepasty application is enabled + When I disable the bepasty application + Then the bepasty site should not be available diff --git a/plinth/modules/bepasty/tests/test_functional.py b/plinth/modules/bepasty/tests/test_functional.py new file mode 100644 index 000000000..b0196a0ea --- /dev/null +++ b/plinth/modules/bepasty/tests/test_functional.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Functional, browser based tests for bepasty app. +""" + +from pytest_bdd import scenarios, then, when + +from plinth.tests import functional + +scenarios('bepasty.feature') + +last_password_added = None + + +@when('I add a password') +def add_password(session_browser): + global last_password_added + _remove_all_passwords(session_browser) + _add_password(session_browser) + last_password_added = _get_password(session_browser) + + +@when('I remove all passwords') +def remove_all_passwords(session_browser): + _remove_all_passwords(session_browser) + + +@then('I should be able to login to bepasty with that password') +def should_login(session_browser): + assert _can_login(session_browser, last_password_added) + + +@then('I should not be able to login to bepasty with that password') +def should_not_login(session_browser): + assert not _can_login(session_browser, last_password_added) + + +def _add_password(browser): + functional.visit(browser, '/plinth/apps/bepasty/add') + for permission in ['read', 'create', 'list', 'delete', 'admin']: + browser.find_by_css('#id_bepasty-permissions input[value="{}"]'.format( + permission)).check() + browser.fill('bepasty-comment', 'bepasty functional test') + functional.submit(browser, form_class='form-add') + + +def _remove_all_passwords(browser): + functional.visit(browser, '/plinth/apps/bepasty') + while True: + remove_button = browser.find_by_css('.password-remove') + if remove_button: + functional.submit(browser, remove_button) + else: + break + + +def _get_password(browser): + functional.visit(browser, '/plinth/apps/bepasty') + return browser.find_by_css('.password-password').first.text + + +def _can_login(browser, password): + functional.visit(browser, '/bepasty') + logout = browser.find_by_value('Logout') + if logout: + logout.click() + + browser.fill('token', password) + login = browser.find_by_xpath('//form//button') + functional.submit(browser, login, '/bepasty') + + return bool(browser.find_by_value('Logout')) diff --git a/plinth/modules/bepasty/urls.py b/plinth/modules/bepasty/urls.py new file mode 100644 index 000000000..544703b0a --- /dev/null +++ b/plinth/modules/bepasty/urls.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +URLs for the bepasty module. +""" + +from django.conf.urls import url + +from .views import AddPasswordView, BepastyView, remove + +urlpatterns = [ + url(r'^apps/bepasty/$', BepastyView.as_view(), name='index'), + url(r'^apps/bepasty/add/$', AddPasswordView.as_view(), name='add'), + url(r'^apps/bepasty/(?P[A-Za-z0-9]+)/remove/$', remove, + name='remove'), +] diff --git a/plinth/modules/bepasty/views.py b/plinth/modules/bepasty/views.py new file mode 100644 index 000000000..edbcf12c5 --- /dev/null +++ b/plinth/modules/bepasty/views.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Views for the bepasty app. +""" + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +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 + +from plinth.modules import bepasty +from plinth.views import AppView + +from .forms import AddPasswordForm + + +class BepastyView(AppView): + """Serve configuration page.""" + app_id = 'bepasty' + diagnostics_module_name = 'bepasty' + template_name = 'bepasty.html' + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['passwords'] = bepasty.list_passwords() + return context + + +class AddPasswordView(SuccessMessageMixin, FormView): + """View to add a new password.""" + form_class = AddPasswordForm + prefix = 'bepasty' + template_name = 'bepasty_add.html' + success_url = reverse_lazy('bepasty:index') + success_message = _('Password added.') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Add Password') + return context + + def form_valid(self, form): + """Add the password on valid form submission.""" + _add_password(form.cleaned_data) + return super().form_valid(form) + + +def _add_password(form_data): + bepasty.add_password(form_data['permissions'], form_data['comment']) + + +@require_POST +def remove(request, password): + """View to remove a password.""" + bepasty.remove_password(password) + messages.success(request, _('Password deleted.')) + return redirect(reverse_lazy('bepasty:index')) diff --git a/static/themes/default/icons/bepasty.png b/static/themes/default/icons/bepasty.png new file mode 100644 index 000000000..37dd561e9 Binary files /dev/null and b/static/themes/default/icons/bepasty.png differ diff --git a/static/themes/default/icons/bepasty.svg b/static/themes/default/icons/bepasty.svg new file mode 100644 index 000000000..ab73c0b48 --- /dev/null +++ b/static/themes/default/icons/bepasty.svg @@ -0,0 +1,22 @@ + + + + + + image/svg+xml + + + + + + + + + + + + B + pasty + + + \ No newline at end of file