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