diff --git a/actions/zoph b/actions/zoph new file mode 100755 index 000000000..3b6b8a831 --- /dev/null +++ b/actions/zoph @@ -0,0 +1,147 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Configuration helper for Zoph server. +""" + +import argparse +import configparser +import json +import os +import re +import subprocess + +from plinth import action_utils + +APACHE_CONF = '/etc/apache2/conf-available/zoph.conf' +DB_BACKUP_FILE = '/var/lib/plinth/backups-data/zoph-database.sql' + + +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('pre-install', + help='Perform Zoph pre-install configuration') + subparser = subparsers.add_parser('setup', + help='Perform Zoph configuration setup') + subparsers.add_parser('get-configuration', + help='Return the current configuration') + subparser = subparsers.add_parser('set-configuration', + help='Configure zoph') + subparser.add_argument('--admin-user', help='Name of the admin user') + subparser.add_argument('--enable-osm', help='Enable OpenSteetMap maps') + subparsers.add_parser('is-configured', help='return true if configured') + subparsers.add_parser('dump-database', help='Dump database to file') + subparsers.add_parser('restore-database', + help='Restore database from file') + + subparsers.required = True + return parser.parse_args() + + +def subcommand_pre_install(_): + """Preseed debconf values before packages are installed.""" + action_utils.debconf_set_selections([ + 'zoph zoph/dbconfig-install boolean true', + 'zoph zoph/mysql/admin-user string root' + ]) + + +def subcommand_get_configuration(_): + """Return the current configuration.""" + configuration = {} + process = subprocess.run(['zoph', '--dump-config'], stdout=subprocess.PIPE, + check=True) + for line in process.stdout.decode().splitlines(): + name, value = line.partition(':')[::2] + configuration[name.strip()] = value[1:] + + print(json.dumps(configuration)) + + +def _zoph_configure(key, value): + """Set a configure value in Zoph.""" + subprocess.run(['zoph', '--config', key, value], check=True) + + +def subcommand_setup(_): + """Setup Zoph configuration.""" + _zoph_configure('import.enable', 'true') + _zoph_configure('import.upload', 'true') + _zoph_configure('import.rotate', 'true') + _zoph_configure('path.unzip', 'unzip') + _zoph_configure('path.untar', 'tar xvf') + _zoph_configure('path.ungz', 'gunzip') + + # Maps using OpenStreetMap is enabled by default. + _zoph_configure('maps.provider', 'osm') + + +def _get_db_name(): + """Return the name of the database configured by dbconfig.""" + config = configparser.ConfigParser() + config.read_file(open('/etc/zoph.ini', 'r')) + return config['zoph']['db_name'].strip('"') + + +def subcommand_set_configuration(arguments): + """Setup Zoph Apache configuration.""" + _zoph_configure('interface.user.remote', 'true') + + # Note that using OpenSteetmap as a mapping provider is a very nice + # feature, but some people may regard its use as a privacy issue + if arguments.enable_osm: + value = 'osm' if arguments.enable_osm == 'True' else '' + _zoph_configure('maps.provider', value) + + if arguments.admin_user: + # Edit the database to rename the admin user to FreedomBox admin user. + admin_user = arguments.admin_user + if not re.match(r'^[\w.@][\w.@-]+\Z', admin_user, flags=re.ASCII): + # Check to avoid SQL injection + raise ValueError('Invalid username') + + query = f"UPDATE zoph_users SET user_name='{admin_user}' \ + WHERE user_name='admin';" + + subprocess.run(['mysql', _get_db_name()], input=query.encode(), + check=True) + + +def subcommand_is_configured(_): + """Print whether zoph app is configured.""" + subprocess.run(['zoph', '--get-config', 'interface.user.remote'], + check=True) + + +def subcommand_dump_database(_): + """Dump database to file.""" + db_name = _get_db_name() + os.makedirs(os.path.dirname(DB_BACKUP_FILE), exist_ok=True) + with open(DB_BACKUP_FILE, 'w') as db_backup_file: + subprocess.run(['mysqldump', db_name], stdout=db_backup_file, + check=True) + + +def subcommand_restore_database(_): + """Restore database from file.""" + db_name = _get_db_name() + subprocess.run(['mysqladmin', '--force', 'drop', db_name]) + subprocess.run(['mysqladmin', 'create', db_name], check=True) + with open(DB_BACKUP_FILE, 'r') as db_restore_file: + subprocess.run(['mysql', db_name], stdin=db_restore_file, check=True) + + +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 3491ea7f6..c0cdc3f70 100644 --- a/debian/copyright +++ b/debian/copyright @@ -69,6 +69,8 @@ Files: static/themes/default/icons/diaspora.png static/themes/default/icons/privoxy.png static/themes/default/icons/privoxy.svg static/themes/default/icons/radicale.svg + static/themes/default/icons/zoph.png + static/themes/default/icons/zoph.svg static/themes/default/img/network-connection.svg static/themes/default/img/network-connection-vertical.svg static/themes/default/img/network-ethernet.svg diff --git a/plinth/modules/zoph/__init__.py b/plinth/modules/zoph/__init__.py new file mode 100644 index 000000000..94cc8dd45 --- /dev/null +++ b/plinth/modules/zoph/__init__.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app to configure Zoph web application +""" + +import json +import logging + +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.modules.apache.components import Webserver +from plinth.modules.backups.components import BackupRestore +from plinth.modules.firewall.components import Firewall +from plinth.utils import format_lazy + +from . import manifest + +logger = logging.getLogger(__name__) + +version = 1 + +managed_packages = ['zoph'] + +_description = [ + format_lazy( + _('Zoph manages your photo collection. Photos are stored on your ' + '{box_name}, under your control. Instead of focusing on galleries ' + 'for public display, Zoph focuses on managing them for your own ' + 'use, organizing them by who took them, where they were taken, ' + 'and who is in them. Photos can be linked to multiple hierarchical ' + 'albums and categories. It is easy to find all photos containing a ' + 'person, or photos taken on a date, or photos taken at a location ' + 'using search, map and calendar views. Individual photos can be ' + 'shared with others by sending a direct link.'), + box_name=_(cfg.box_name)), + format_lazy( + _('The {box_name} user who setup Zoph will also become the ' + 'administrator in Zoph. For additional users, accounts must be ' + 'created both in {box_name} and in Zoph with the same user name.'), + box_name=_(cfg.box_name)) +] + +app = None + + +class ZophApp(app_module.App): + """FreedomBox app for Zoph.""" + + app_id = 'zoph' + + def __init__(self): + """Create components for the app.""" + super().__init__() + info = app_module.Info(app_id=self.app_id, version=version, + name=_('Zoph'), icon_filename='zoph', + short_description=_('Photo Organizer'), + description=_description, manual_page='Zoph', + clients=manifest.clients) + self.add(info) + + menu_item = menu.Menu('menu-zoph', info.name, info.short_description, + info.icon_filename, 'zoph:index', + parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-zoph', info.name, + short_description=info.short_description, + icon=info.icon_filename, url='/zoph/', + clients=info.clients, + login_required=True) + self.add(shortcut) + + firewall = Firewall('firewall-zoph', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + webserver = Webserver('webserver-zoph', 'zoph', + urls=['https://{host}/zoph/']) + self.add(webserver) + + backup_restore = ZophBackupRestore('backup-restore-zoph', + **manifest.backup) + self.add(backup_restore) + + +def setup(helper, old_version=None): + """Install and configure the module.""" + helper.call('pre', actions.superuser_run, 'zoph', ['pre-install']) + helper.install(managed_packages) + helper.call('post', actions.superuser_run, 'zoph', ['setup']) + helper.call('post', app.enable) + + +def set_configuration(admin_user=None, enable_osm=None): + """Configure Zoph.""" + args = [] + if admin_user: + args += ['--admin-user', admin_user] + + if enable_osm is not None: + args += ['--enable-osm', str(enable_osm)] + + actions.superuser_run('zoph', ['set-configuration'] + args) + + +def is_configured(): + """Return whether the Zoph app is configured.""" + output = actions.superuser_run('zoph', ['is-configured']) + return output.strip() == 'true' + + +def get_configuration(): + """Return full configuration of Zoph.""" + configuration = actions.superuser_run('zoph', ['get-configuration']) + return json.loads(configuration) + + +class ZophBackupRestore(BackupRestore): + """Component to backup/restore Zoph database""" + + def backup_pre(self, packet): + """Save database contents.""" + actions.superuser_run('zoph', ['dump-database']) + + def restore_post(self, packet): + """Restore database contents.""" + actions.superuser_run('zoph', ['restore-database']) diff --git a/plinth/modules/zoph/data/etc/plinth/modules-enabled/zoph b/plinth/modules/zoph/data/etc/plinth/modules-enabled/zoph new file mode 100644 index 000000000..a33721589 --- /dev/null +++ b/plinth/modules/zoph/data/etc/plinth/modules-enabled/zoph @@ -0,0 +1 @@ +plinth.modules.zoph diff --git a/plinth/modules/zoph/forms.py b/plinth/modules/zoph/forms.py new file mode 100644 index 000000000..11a149e88 --- /dev/null +++ b/plinth/modules/zoph/forms.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for configuring Zoph. +""" + +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class ZophForm(forms.Form): + """Zoph application configuration form.""" + + enable_osm = forms.BooleanField( + label=_('Enable OpenStreetMap for maps'), required=False, + help_text=_('When enabled, requests will be made to OpenStreetMap ' + 'servers from user\'s browser. This impacts privacy.')) diff --git a/plinth/modules/zoph/manifest.py b/plinth/modules/zoph/manifest.py new file mode 100644 index 000000000..bf6ea5268 --- /dev/null +++ b/plinth/modules/zoph/manifest.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.utils.translation import ugettext_lazy as _ + +clients = [{ + 'name': _('Zoph'), + 'platforms': [{ + 'type': 'web', + 'url': '/zoph/', + }] +}] + +backup = { + 'data': { + 'files': ['/var/lib/plinth/backups-data/zoph-database.sql'], + 'directories': ['/var/lib/zoph/'] + }, + 'secrets': { + 'files': ['/etc/zoph.ini'], + } +} diff --git a/plinth/modules/zoph/templates/zoph-pre-setup.html b/plinth/modules/zoph/templates/zoph-pre-setup.html new file mode 100644 index 000000000..0eb12544e --- /dev/null +++ b/plinth/modules/zoph/templates/zoph-pre-setup.html @@ -0,0 +1,31 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block pagetitle %} +
+ {% blocktrans trimmed with username=request.user.username %} + User account {{ username }} will become the administrator + account for Zoph. + {% endblocktrans %} +
+ + + +{% endblock %} diff --git a/plinth/modules/zoph/tests/__init__.py b/plinth/modules/zoph/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/zoph/tests/test_functional.py b/plinth/modules/zoph/tests/test_functional.py new file mode 100644 index 000000000..f0ebab73a --- /dev/null +++ b/plinth/modules/zoph/tests/test_functional.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Functional, browser based tests for zoph app. +""" + +from pytest_bdd import given, parsers, scenarios + +from plinth.tests import functional + +scenarios('zoph.feature') + + +@given(parsers.parse('the zoph application is setup')) +def _zoph_is_setup(session_browser): + """Click setup button on the setup page.""" + functional.nav_to_module(session_browser, 'zoph') + button = session_browser.find_by_css('input[name="zoph_setup"]') + if button: + functional.submit(session_browser, element=button) diff --git a/plinth/modules/zoph/tests/zoph.feature b/plinth/modules/zoph/tests/zoph.feature new file mode 100644 index 000000000..e7c655794 --- /dev/null +++ b/plinth/modules/zoph/tests/zoph.feature @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +@apps @zoph +Feature: Zoph Organises PHotos + Run photo organiser + +Background: + Given I'm a logged in user + Given the zoph application is installed + Given the zoph application is setup + +Scenario: Enable zoph application + Given the zoph application is disabled + When I enable the zoph application + Then the zoph application is enabled + +@backups +Scenario: Backup and restore zoph + Given the zoph application is enabled + When I create a backup of the zoph app data with name test_zoph + And I restore the zoph app data backup with name test_zoph + Then the zoph application is enabled + +Scenario: Disable zoph application + Given the zoph application is enabled + When I disable the zoph application + Then the zoph application is disabled diff --git a/plinth/modules/zoph/urls.py b/plinth/modules/zoph/urls.py new file mode 100644 index 000000000..15e49d964 --- /dev/null +++ b/plinth/modules/zoph/urls.py @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +URLs for the Zoph module. +""" + +from django.conf.urls import url + +from .views import SetupView, ZophAppView + +urlpatterns = [ + url(r'^apps/zoph/setup/$', SetupView.as_view(), name='setup'), + url(r'^apps/zoph/$', ZophAppView.as_view(app_id='zoph'), name='index') +] diff --git a/plinth/modules/zoph/views.py b/plinth/modules/zoph/views.py new file mode 100644 index 000000000..067179853 --- /dev/null +++ b/plinth/modules/zoph/views.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for configuring Zoph photo organiser. +""" + +import logging + +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic import TemplateView + +from plinth import views +from plinth.errors import ActionError +from plinth.modules import zoph + +from .forms import ZophForm + +logger = logging.getLogger(__name__) + + +class SetupView(TemplateView): + """Show zoph setup page.""" + template_name = 'zoph-pre-setup.html' + success_url = reverse_lazy('zoph:index') + + def get_context_data(self, *args, **kwargs): + """Provide context data to the template.""" + context = super().get_context_data(**kwargs) + context['title'] = zoph.app.info.name + context['app_info'] = zoph.app.info + return context + + def post(self, _request, *args, **kwargs): + """Handle form submission.""" + admin_user = self.request.user.get_username() + zoph.set_configuration(admin_user=admin_user) + return HttpResponseRedirect(reverse_lazy('zoph:index')) + + +class ZophAppView(views.AppView): + """App configuration page.""" + form_class = ZophForm + app_id = 'zoph' + + def dispatch(self, request, *args, **kwargs): + """Redirect to setup page if setup is not done yet.""" + if not zoph.is_configured(): + return redirect('zoph:setup') + + return super().dispatch(request, *args, **kwargs) + + def get_initial(self): + """Get the current settings from Zoph.""" + status = super().get_initial() + config = zoph.get_configuration() + status['enable_osm'] = (config['maps.provider'] == 'osm') + return status + + def form_valid(self, form): + """Apply the changes submitted in the form.""" + old_status = form.initial + new_status = form.cleaned_data + if old_status['enable_osm'] != new_status['enable_osm']: + try: + zoph.set_configuration(enable_osm=new_status['enable_osm']) + messages.success(self.request, _('Configuration updated.')) + except ActionError: + messages.error(self.request, + _('An error occurred during configuration.')) + + return super().form_valid(form) diff --git a/static/themes/default/icons/zoph.png b/static/themes/default/icons/zoph.png new file mode 100644 index 000000000..cc8b9d093 Binary files /dev/null and b/static/themes/default/icons/zoph.png differ diff --git a/static/themes/default/icons/zoph.svg b/static/themes/default/icons/zoph.svg new file mode 100644 index 000000000..f718e008a --- /dev/null +++ b/static/themes/default/icons/zoph.svg @@ -0,0 +1,522 @@ + + + +