diff --git a/actions/coquelicot b/actions/coquelicot new file mode 100755 index 000000000..340fbae50 --- /dev/null +++ b/actions/coquelicot @@ -0,0 +1,137 @@ +#!/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 coquelicot. +""" + +import argparse +import hashlib +import os +import sys + +import yaml +from plinth import action_utils + +SETTINGS_FILE = '/etc/coquelicot/settings.yml' + + +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='Post-installation operations for coquelicot') + subparsers.add_parser('enable', help='Enable coquelicot') + subparsers.add_parser('disable', help='Disable coquelicot') + + subparsers.add_parser( + 'set-upload-password', + help='Set a new global, pre-shared password for uploading files') + + max_file_size = subparsers.add_parser( + 'set-max-file-size', + help='Change the maximum size of the files that can be uploaded to ' + 'Coquelicot') + max_file_size.add_argument('size', type=int, help='upload file size in MB') + + subparsers.add_parser( + 'get-max-file-size', + help='Print the maximum size of the files that can be uploaded to ' + 'Coquelicot') + + subparsers.required = True + return parser.parse_args() + + +def subcommand_setup(_): + """Perform post-installation operations for coquelicot.""" + settings = read_settings() + settings['path'] = "/coquelicot" + settings['max_file_size'] = mebibytes(1024) + write_settings(settings) + action_utils.service_restart('coquelicot') + + +def subcommand_enable(_): + """Enable web configuration and reload.""" + action_utils.service_enable('coquelicot') + action_utils.webserver_enable('coquelicot-freedombox') + + +def subcommand_disable(_): + """Disable web configuration and reload.""" + action_utils.webserver_disable('coquelicot-freedombox') + action_utils.service_disable('coquelicot') + + +def subcommand_set_upload_password(arguments): + """Set a new upload password for Coquelicot.""" + upload_password = ''.join(sys.stdin) + settings = read_settings() + hashed_pw = hashlib.sha1(upload_password.encode()).hexdigest() + settings['authentication_method']['upload_password'] = hashed_pw + write_settings(settings) + action_utils.service_try_restart('coquelicot') + + +def subcommand_set_max_file_size(arguments): + """Set a new maximum file size for Coquelicot.""" + size_in_bytes = mebibytes(arguments.size) + settings = read_settings() + settings['max_file_size'] = size_in_bytes + write_settings(settings) + action_utils.service_try_restart('coquelicot') + + +def subcommand_get_max_file_size(_): + """Print the maximum file size to stdout.""" + if os.path.exists(SETTINGS_FILE): + settings = read_settings() + print(int(settings['max_file_size'] / (1024 * 1024))) + else: + print(-1) + + +def read_settings(): + with open(SETTINGS_FILE, 'rb') as settings_file: + return yaml.load(settings_file) + + +def write_settings(settings): + with open(SETTINGS_FILE, 'w') as settings_file: + yaml.dump(settings, settings_file) + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +def mebibytes(size): + """Return the given size of mebibytes in bytes.""" + return size * 1024 * 1024 + + +if __name__ == '__main__': + main() diff --git a/data/etc/apache2/conf-available/coquelicot-freedombox.conf b/data/etc/apache2/conf-available/coquelicot-freedombox.conf new file mode 100644 index 000000000..0fe851e83 --- /dev/null +++ b/data/etc/apache2/conf-available/coquelicot-freedombox.conf @@ -0,0 +1,5 @@ + + ProxyPass http://127.0.0.1:51161/coquelicot + SetEnv proxy-sendchunks 1 + RequestHeader set X-Forwarded-SSL "on" + diff --git a/data/etc/plinth/modules-enabled/coquelicot b/data/etc/plinth/modules-enabled/coquelicot new file mode 100644 index 000000000..2b2d71614 --- /dev/null +++ b/data/etc/plinth/modules-enabled/coquelicot @@ -0,0 +1 @@ +plinth.modules.coquelicot diff --git a/plinth/action_utils.py b/plinth/action_utils.py index e5acc96b5..6a4dc71ee 100644 --- a/plinth/action_utils.py +++ b/plinth/action_utils.py @@ -99,41 +99,36 @@ def service_unmask(service_name): def service_start(service_name): """Start a service with systemd or sysvinit.""" - if is_systemd_running(): - subprocess.run(['systemctl', 'start', service_name], - stdout=subprocess.DEVNULL) - else: - subprocess.run(['service', service_name, 'start'], - stdout=subprocess.DEVNULL) + service_action(service_name, 'start') def service_stop(service_name): """Stop a service with systemd or sysvinit.""" - if is_systemd_running(): - subprocess.run(['systemctl', 'stop', service_name], - stdout=subprocess.DEVNULL) - else: - subprocess.run(['service', service_name, 'stop'], - stdout=subprocess.DEVNULL) + service_action(service_name, 'stop') def service_restart(service_name): """Restart a service with systemd or sysvinit.""" - if is_systemd_running(): - subprocess.run(['systemctl', 'restart', service_name], - stdout=subprocess.DEVNULL) - else: - subprocess.run(['service', service_name, 'restart'], - stdout=subprocess.DEVNULL) + service_action(service_name, 'restart') + + +def service_try_restart(service_name): + """Try to restart a service with systemd or sysvinit.""" + service_action(service_name, 'try-restart') def service_reload(service_name): """Reload a service with systemd or sysvinit.""" + service_action(service_name, 'reload') + + +def service_action(service_name, action): + """Preform the given action on the service_name.""" if is_systemd_running(): - subprocess.run(['systemctl', 'reload', service_name], + subprocess.run(['systemctl', action, service_name], stdout=subprocess.DEVNULL) else: - subprocess.run(['service', service_name, 'reload'], + subprocess.run(['service', service_name, action], stdout=subprocess.DEVNULL) diff --git a/plinth/modules/coquelicot/__init__.py b/plinth/modules/coquelicot/__init__.py new file mode 100644 index 000000000..cc8c70618 --- /dev/null +++ b/plinth/modules/coquelicot/__init__.py @@ -0,0 +1,133 @@ +# +# 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 coquelicot. +""" + +from django.utils.translation import ugettext_lazy as _ + +from plinth import service as service_module +from plinth import action_utils, actions, frontpage +from plinth.menu import main_menu + +from .manifest import clients + +clients = clients + +version = 1 + +managed_services = ['coquelicot'] + +managed_packages = ['coquelicot'] + +name = _('Coquelicot') + +short_description = _('File Sharing') + +description = [ + _('Coquelicot is a “one-click” file sharing web application with a focus ' + 'on protecting users’ privacy. It is best used for quickly sharing a ' + 'single file. '), + _('This Coquelicot instance is exposed to the public but requires an ' + 'upload password to prevent unauthorized access. You can set a new ' + 'upload password in the form that will appear below after installation. ' + 'The default upload password is "test".') +] + +service = None + + +def init(): + """Intialize the module.""" + menu = main_menu.get('apps') + menu.add_urlname(name, 'glyphicon-open-file', 'coquelicot:index', + short_description) + + global service + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() != 'needs-setup': + service = service_module.Service(managed_services[0], name, ports=[ + 'http', 'https' + ], 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) + helper.call('post', actions.superuser_run, 'coquelicot', ['setup']) + helper.call('post', actions.superuser_run, 'coquelicot', ['enable']) + global service + if service is None: + service = service_module.Service(managed_services[0], name, ports=[ + 'http', 'https' + ], is_external=True, is_enabled=is_enabled, enable=enable, + disable=disable, + is_running=is_running) + helper.call('post', service.notify_enabled, None, True) + helper.call('post', add_shortcut) + + +def add_shortcut(): + """Helper method to add a shortcut to the frontpage.""" + frontpage.add_shortcut('coquelicot', name, + short_description=short_description, + url='/coquelicot', login_required=True) + + +def is_running(): + """Return whether the service is running.""" + return action_utils.service_is_running('coquelicot') + + +def is_enabled(): + """Return whether the module is enabled.""" + return (action_utils.service_is_enabled('coquelicot') + and action_utils.webserver_is_enabled('coquelicot-freedombox')) + + +def enable(): + """Enable the module.""" + actions.superuser_run('coquelicot', ['enable']) + add_shortcut() + + +def disable(): + """Disable the module.""" + actions.superuser_run('coquelicot', ['disable']) + frontpage.remove_shortcut('coquelicot') + + +def get_current_max_file_size(): + """Get the current value of maximum file size.""" + size = actions.superuser_run('coquelicot', ['get-max-file-size']) + return int(size.strip()) + + +def diagnose(): + """Run diagnostics and return the results.""" + results = [] + + results.extend( + action_utils.diagnose_url_on_all('https://{host}/coquelicot', + check_certificate=False)) + + return results diff --git a/plinth/modules/coquelicot/forms.py b/plinth/modules/coquelicot/forms.py new file mode 100644 index 000000000..1d8535f5f --- /dev/null +++ b/plinth/modules/coquelicot/forms.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 . +# +""" +Plinth form for configuring Coquelicot. +""" + +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from plinth.forms import ServiceForm + + +class CoquelicotForm(ServiceForm): # pylint: disable=W0232 + """Coquelicot configuration form.""" + upload_password = forms.CharField( + label=_('Upload Password'), + help_text=_('Set a new upload password for Coquelicot. ' + 'Leave this field blank to keep the current password.'), + required=False, widget=forms.PasswordInput) + max_file_size = forms.IntegerField( + label=_("Maximum File Size (in MiB)"), help_text=_( + 'Set the maximum size of the files that can be uploaded to ' + 'Coquelicot.'), required=False, min_value=0) diff --git a/plinth/modules/coquelicot/manifest.py b/plinth/modules/coquelicot/manifest.py new file mode 100644 index 000000000..88a17ba54 --- /dev/null +++ b/plinth/modules/coquelicot/manifest.py @@ -0,0 +1,28 @@ +# +# 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 . +# + +from django.utils.translation import ugettext_lazy as _ + +from plinth.clients import validate + +clients = validate([{ + 'name': _('coquelicot'), + 'platforms': [{ + 'type': 'web', + 'url': '/coquelicot' + }] +}]) diff --git a/plinth/modules/coquelicot/urls.py b/plinth/modules/coquelicot/urls.py new file mode 100644 index 000000000..d1934ba38 --- /dev/null +++ b/plinth/modules/coquelicot/urls.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 . +# +""" +URLs for the coquelicot module. +""" + +from django.conf.urls import url + +from .views import CoquelicotServiceView + +urlpatterns = [ + url(r'^apps/coquelicot/$', CoquelicotServiceView.as_view(), name='index'), +] diff --git a/plinth/modules/coquelicot/views.py b/plinth/modules/coquelicot/views.py new file mode 100644 index 000000000..b1f95276f --- /dev/null +++ b/plinth/modules/coquelicot/views.py @@ -0,0 +1,72 @@ +# +# 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 views for Coquelicot. +""" + +from django.contrib import messages +from django.utils.translation import ugettext as _ + +from plinth import actions, views +from plinth.errors import ActionError +from plinth.modules.coquelicot import (clients, description, + get_current_max_file_size) + +from .forms import CoquelicotForm + + +class CoquelicotServiceView(views.ServiceView): + """Serve configuration page.""" + clients = clients + description = description + diagnostics_module_name = 'coquelicot' + service_id = 'coquelicot' + form_class = CoquelicotForm + show_status_block = True + + def get_initial(self): + """Return the status of the service to fill in the form.""" + initial = super().get_initial() + initial['max_file_size'] = get_current_max_file_size() + return initial + + def form_valid(self, form): + """Apply the changes submitted in the form.""" + form_data = form.cleaned_data + + if form_data['upload_password']: + try: + actions.superuser_run( + 'coquelicot', ['set-upload-password'], + input=form_data['upload_password'].encode()) + messages.success(self.request, _('Upload password updated')) + except ActionError as e: + messages.error(self.request, + _('Failed to update upload password')) + + max_file_size = form_data['max_file_size'] + if max_file_size and max_file_size != get_current_max_file_size(): + try: + actions.superuser_run( + 'coquelicot', ['set-max-file-size', + str(max_file_size)]) + messages.success(self.request, _('Maximum file size updated')) + except ActionError as e: + messages.error(self.request, + _('Failed to update maximum file size')) + + return super().form_valid(form) diff --git a/static/themes/default/icons/coquelicot.png b/static/themes/default/icons/coquelicot.png new file mode 100644 index 000000000..9bb173f83 Binary files /dev/null and b/static/themes/default/icons/coquelicot.png differ