From 6887c960fea21b8e597f324dd7fc046109f45928 Mon Sep 17 00:00:00 2001 From: Benedek Nagy Date: Fri, 27 Dec 2024 21:49:35 +0100 Subject: [PATCH] sogo: Add a new app for SOGo groupware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOGo is an open source webmail client and groupware available in Debian. Make a new FreedomBox app for it to be used with the local Postfix/Dovecot email server. SOGo requires a database to store events/tasks as well as user settings. Memcached is also required for caching. Users log in with their username (as opposed to username + domain on Roundcube). The host header seen from the first login will be associated with the user profile. So, if a user logs into SOGo from freedombox.local and later configures the email server to use example.com, they will manually have to edit their account(s) to show the updated domain. Authentication is done via openldap. It is possible to authenticate with apache, however it is limited to http basic auth, so mod_auth_tkt cannot be used. See: https://github.com/Alinto/sogo/blob/b40d777a86104d08dbd8168b8e12c198a3d4321a/SoObjects/SOGo/SOGoProxyAuthenticator.m#L137 Configuring http basic auth in my opinion wouldn't add much to the user experience. It would actually take away the usage of SOGO's built in TOTP feature. SOGo only accepts configurations from /etc/sogo/sogo.conf, other configs from sogo.d don't get recognised. Use the sogo icon from upstream source. Update sogo.png and sogo.svg to be the same image (but resized) that is provided in the upstream source. The previous image was download from Wikimedia Commons. Update smtp settings so that messages can be sent Test result for mail deliverability sent with SOGo: https://www.mail-tester.com/test-pdf2yzy6n The result shows that the message is not DKIM signed. This seems to be an issue not specific to SOGo. Mails sent from Thunderbird don't get signed either. Tests: - Install app and log in with a FreedomBox user. Create a new event titled "Lunch with 🍕 and fries". Confirm the pizza character displays properly. - Backup the app and uninstall it. - Restore from the backup, log in and confirm the event gets restored. To-do: - test ActiveSync - create a fail2ban jail - include the icons in the copyright file - test sending email in a production setup - test sieve filters - write tests https://salsa.debian.org/freedombox-team/freedombox/-/issues/56 [Sunil] - App: - Update icons to be uniform size as all other apps and copyright information. - Since SOGo is not configured to trust the authentication from Apache, it does not require FirewallLocalProtection. Remove it. - Expand app description. Talk about Email Server app. - Update to match recent tags related changes. - Make memcached a shared daemon as other processes might use it. - Added shared daemon for PostgreSQL. - Don't start services when rerunning setup if the app is currently disabled. - Don't restart memcached during a restore operation. - Security: - Add system security restrictions to the daemon. - Don't use fail2ban jail. SOGo has a mechanism to lock users for a few minutes. Use that instead. - Apache: - Make /.well-known URLs work by moving their definitions to global section. - Remove old (<2.4) Apache authorization keywords. - Simplify, indentation, new line at EOF. - Manifest: - Add more tags. - Add SOGo connector, DAVx5, and GNOME Calendar to list of clients. - Add 'sogo' to list of service to bring down during backup/restore. - Privileged: - Switch from MySQL to PostgreSQL as it is recommended by SOGo. - Use existing utility to generate database password. - Use plget and plmerge utilities from gnustep-common package to parse/edit the configuration instead of augeas which don't have a dedicated lens. - Don't reset the domain when rerunning setup. - Ensure that the configuration file has proper ownership and permissions even when it did not exist previously. - Add typing information for most methods. - Remove configuration file after uninstall. - Configuration: - Define database URLs for all seven database tables. - Set calendar default roles as suggested in the installation guide. - Refresh view automatically every minute to check for new mail. - Use the mechanism to lock account after failed login attempts. - Add folder name for Junk folder too explicitly. - Tests: Add basic functional tests. Tests: - Functional tests work. - Rerunning setup does not change the domain back to the primary domain of the email server. - Login works. Sending mail and reading mail works. Creating calendar events and contact works. - Changing the domain sets the domain value properly in the configuration file. Configured domain is shown properly on the form. - Backup and restore work as expected. - When configuration file is removed and setup is re-run, then the file is created with proper ownership and permissions. - 'systemd-analyze security sogo.service' shows a good score. Signed-off-by: Benedek Nagy Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- debian/copyright | 6 + plinth/modules/sogo/__init__.py | 121 +++++++++++++ .../system/sogo.service.d/freedombox.conf | 28 +++ .../conf-available/sogo-freedombox.conf | 36 ++++ .../usr/share/freedombox/modules-enabled/sogo | 1 + plinth/modules/sogo/forms.py | 23 +++ plinth/modules/sogo/manifest.py | 77 +++++++++ plinth/modules/sogo/privileged.py | 161 ++++++++++++++++++ plinth/modules/sogo/static/icons/sogo.png | Bin 0 -> 11932 bytes plinth/modules/sogo/static/icons/sogo.svg | 46 +++++ plinth/modules/sogo/tests/__init__.py | 0 plinth/modules/sogo/tests/test_functional.py | 42 +++++ plinth/modules/sogo/urls.py | 10 ++ plinth/modules/sogo/views.py | 34 ++++ plinth/tests/functional/__init__.py | 1 + 15 files changed, 586 insertions(+) create mode 100644 plinth/modules/sogo/__init__.py create mode 100644 plinth/modules/sogo/data/usr/lib/systemd/system/sogo.service.d/freedombox.conf create mode 100644 plinth/modules/sogo/data/usr/share/freedombox/etc/apache2/conf-available/sogo-freedombox.conf create mode 100644 plinth/modules/sogo/data/usr/share/freedombox/modules-enabled/sogo create mode 100644 plinth/modules/sogo/forms.py create mode 100644 plinth/modules/sogo/manifest.py create mode 100644 plinth/modules/sogo/privileged.py create mode 100644 plinth/modules/sogo/static/icons/sogo.png create mode 100644 plinth/modules/sogo/static/icons/sogo.svg create mode 100644 plinth/modules/sogo/tests/__init__.py create mode 100644 plinth/modules/sogo/tests/test_functional.py create mode 100644 plinth/modules/sogo/urls.py create mode 100644 plinth/modules/sogo/views.py diff --git a/debian/copyright b/debian/copyright index 52c0f3fce..06eea8862 100644 --- a/debian/copyright +++ b/debian/copyright @@ -275,6 +275,12 @@ Copyright: Interface (https://www.shareicon.net/author/interface) Comment: https://www.shareicon.net/universal-interface-interface-sharing-share-697502 License: CC-BY-3.0 +Files: plinth/modules/sogo/static/icons/sogo.png + plinth/modules/sogo/static/icons/sogo.svg +Copyright: 2024 Inverse inc./Alinto +Comment: https://github.com/Alinto/sogo/blob/master/COPYING.GPL +License: GPL-2 + Files: plinth/modules/syncthing/static/icons/syncthing.png Copyright: 2015 Jack Palevich 2014 The Syncthing Authors diff --git a/plinth/modules/sogo/__init__.py b/plinth/modules/sogo/__init__.py new file mode 100644 index 000000000..5ac09821f --- /dev/null +++ b/plinth/modules/sogo/__init__.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""FreedomBox app to configure SOGo.""" + +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.config import DropinConfigs +from plinth.daemon import Daemon, SharedDaemon +from plinth.modules.apache.components import Webserver +from plinth.modules.backups.components import BackupRestore +from plinth.modules.firewall.components import Firewall +from plinth.package import Packages +from plinth.privileged import service as service_privileged +from plinth.utils import format_lazy + +from . import manifest, privileged + +_description = [ + _('SOGo is a groupware server that provides a rich web interface for ' + 'email, calendar, tasks, and contacts. Calendar, tasks, and contacts ' + 'can also be accessed with various mobile and desktop applications ' + 'using the CalDAV and CardDAV standards.'), + format_lazy( + _('Webmail works with the Postfix/Dovecot ' + 'email server app to retrieve, manage, and send email.'), + email_url=reverse_lazy('email:index')), + format_lazy( + _('All users on {box_name} can login into and use SOGo. Mails ' + 'delivered to their mailboxes by the email server app can be read ' + 'and new mail can be sent out.'), box_name=_(cfg.box_name)), +] + + +class SOGoApp(app_module.App): + """FreedomBox app for SOGo.""" + + app_id = 'sogo' + + _version = 1 + + def __init__(self) -> None: + """Create components for the app.""" + super().__init__() + + info = app_module.Info(app_id=self.app_id, version=self._version, + depends=['email'], name=_('SOGo'), + icon_filename='sogo', description=_description, + manual_page='SOGo', clients=manifest.clients, + tags=manifest.tags) + self.add(info) + + menu_item = menu.Menu('menu-sogo', info.name, info.icon_filename, + info.tags, 'sogo:index', parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-sogo', info.name, + icon=info.icon_filename, url='/SOGo/', + clients=info.clients, tags=info.tags) + self.add(shortcut) + + packages = Packages('packages-sogo', + ['sogo', 'postgresql', 'memcached']) + self.add(packages) + + dropin_configs = DropinConfigs('dropin-configs-sogo', [ + '/etc/apache2/conf-available/sogo-freedombox.conf', + ]) + self.add(dropin_configs) + + firewall = Firewall('firewall-sogo', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + webserver = Webserver('webserver-sogo', 'sogo-freedombox', + urls=['https://{host}/SOGo/']) + self.add(webserver) + + daemon1 = SharedDaemon('shared-daemon-sogo-memcached', 'memcached', + listen_ports=[(11211, 'tcp4')]) + self.add(daemon1) + + daemon2 = SharedDaemon('shared-daemon-sogo-postgresql', 'postgresql') + self.add(daemon2) + + daemon3 = Daemon('daemon-sogo', 'sogo', listen_ports=[(20000, 'tcp4')]) + self.add(daemon3) + + backup_restore = SOGoBackupRestore('backup-restore-sogo', + **manifest.backup) + self.add(backup_restore) + + def setup(self, old_version): + """Install and configure the app.""" + super().setup(old_version) + privileged.setup() + service_privileged.try_restart('sogo') + service_privileged.try_restart('memcached') + + if not old_version: + self.enable() + + def uninstall(self): + """De-configure and uninstall the app.""" + super().uninstall() + privileged.uninstall() + + +class SOGoBackupRestore(BackupRestore): + """Component to backup/restore SOGo.""" + + def backup_pre(self, packet): + """Save database contents.""" + super().backup_pre(packet) + privileged.dump_database() + + def restore_post(self, packet): + """Restore database contents.""" + super().restore_post(packet) + privileged.restore_database() diff --git a/plinth/modules/sogo/data/usr/lib/systemd/system/sogo.service.d/freedombox.conf b/plinth/modules/sogo/data/usr/lib/systemd/system/sogo.service.d/freedombox.conf new file mode 100644 index 000000000..cde0401c1 --- /dev/null +++ b/plinth/modules/sogo/data/usr/lib/systemd/system/sogo.service.d/freedombox.conf @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +[Service] +CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER CAP_NET_ADMIN CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE CAP_KILL CAP_NET_BIND_SERVICE CAP_NET_BROADCAST CAP_NET_RAW CAP_LINUX_IMMUTABLE CAP_IPC_LOCK CAP_SYS_CHROOT CAP_BLOCK_SUSPEND CAP_LEASE CAP_SYS_PACCT CAP_SYS_TTY_CONFIG CAP_SYS_BOOT CAP_MAC_ADMIN CAP_MAC_OVERRIDE CAP_SYS_NICE CAP_SYS_RESOURCE CAP_BPF +DevicePolicy=closed +LockPersonality=yes +NoNewPrivileges=yes +PrivateDevices=yes +PrivateMounts=yes +PrivateTmp=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +ReadWritePaths=/var/spool/sogo /var/log/sogo +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@resources +SystemCallFilter=~@privileged +SystemCallErrorNumber=EPERM diff --git a/plinth/modules/sogo/data/usr/share/freedombox/etc/apache2/conf-available/sogo-freedombox.conf b/plinth/modules/sogo/data/usr/share/freedombox/etc/apache2/conf-available/sogo-freedombox.conf new file mode 100644 index 000000000..421bf8a91 --- /dev/null +++ b/plinth/modules/sogo/data/usr/share/freedombox/etc/apache2/conf-available/sogo-freedombox.conf @@ -0,0 +1,36 @@ +## +## On all sites, provide SOGo on a default path: /SOGo +## https://www.sogo.nu/support/faq/how-to-configure-apache-as-frontend.html +## + +Alias /SOGo.woa/WebServerResources/ /usr/lib/GNUstep/SOGo/WebServerResources/ +Alias /SOGo/WebServerResources/ /usr/lib/GNUstep/SOGo/WebServerResources/ + +Redirect 301 /.well-known/caldav /SOGo/dav +Redirect 301 /.well-known/carddav /SOGo/dav + + + ProxyPass http://127.0.0.1:20000/SOGo retry=0 nocanon + ProxyPreserveHost On + SetEnv proxy-nokeepalive 1 + + + RequestHeader set "x-webobjects-server-port" "443" + SetEnvIf Host (.*) HTTP_HOST=$1 + RequestHeader set "x-webobjects-server-name" "%{HTTP_HOST}e" env=HTTP_HOST + RequestHeader set "x-webobjects-server-url" "https://%{HTTP_HOST}e" env=HTTP_HOST + + + AddDefaultCharset UTF-8 + + + + Require all granted + # Explicitly allow caching of static content to avoid browser specific + # behavior. A resource's URL MUST change in order to have the client load + # the new version. + + ExpiresActive On + ExpiresDefault "access plus 1 year" + + diff --git a/plinth/modules/sogo/data/usr/share/freedombox/modules-enabled/sogo b/plinth/modules/sogo/data/usr/share/freedombox/modules-enabled/sogo new file mode 100644 index 000000000..093376882 --- /dev/null +++ b/plinth/modules/sogo/data/usr/share/freedombox/modules-enabled/sogo @@ -0,0 +1 @@ +plinth.modules.sogo diff --git a/plinth/modules/sogo/forms.py b/plinth/modules/sogo/forms.py new file mode 100644 index 000000000..dadfb4d75 --- /dev/null +++ b/plinth/modules/sogo/forms.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Forms for the SOGo app.""" + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from plinth.modules.names.components import DomainName + + +def _get_domain_choices(): + """Double domain entries for inclusion in the choice field.""" + return ((domain.name, domain.name) for domain in DomainName.list()) + + +class DomainForm(forms.Form): + domain = forms.ChoiceField( + choices=_get_domain_choices, + label=_('Domain'), + help_text=_( + 'Mails are received for all domains configured in the system. ' + 'Among these, select the most important one.'), + required=True, + ) diff --git a/plinth/modules/sogo/manifest.py b/plinth/modules/sogo/manifest.py new file mode 100644 index 000000000..ed0cb11d1 --- /dev/null +++ b/plinth/modules/sogo/manifest.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.utils.translation import gettext_lazy as _ + +from plinth.clients import store_url + +from . import privileged + +clients = [ + { + 'name': _('SOGo'), + 'platforms': [{ + 'type': 'web', + 'url': '/SOGo/' + }] + }, + { + 'name': + _('Thunderbird + SOGo connector'), + 'platforms': [{ + 'type': 'download', + 'os': 'gnu-linux', + 'url': 'https://www.sogo.nu/download.html#/frontends' + }, { + 'type': 'download', + 'os': 'macos', + 'url': 'https://www.sogo.nu/download.html#/frontends' + }, { + 'type': 'download', + 'os': 'windows', + 'url': 'https://www.sogo.nu/download.html#/frontends' + }] + }, + { + 'name': + _('DAVx5'), + 'platforms': [{ + 'type': 'store', + 'os': 'android', + 'store_name': 'f-droid', + 'url': store_url('f-droid', 'at.bitfire.davdroid'), + }, { + 'type': 'store', + 'os': 'android', + 'store_name': 'google-play', + 'url': store_url('google-play', 'at.bitfire.davdroid'), + }] + }, + { + 'name': + _('GNOME Calendar'), + 'platforms': [{ + 'type': 'package', + 'format': 'deb', + 'name': 'gnome-calendar' + }] + }, +] + +backup = { + 'data': { + 'files': [str(privileged.DB_BACKUP_FILE)], + }, + 'services': ['sogo'], + 'secrets': { + 'directories': [str(privileged.CONFIG_FILE)] + }, +} + +tags = [ + _('Webmail'), + _('Groupware'), + _('Calender'), + _('Address book'), + _('CalDAV'), + _('CardDAV') +] diff --git a/plinth/modules/sogo/privileged.py b/plinth/modules/sogo/privileged.py new file mode 100644 index 000000000..0de0f8076 --- /dev/null +++ b/plinth/modules/sogo/privileged.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure SOGo.""" + +import pathlib +import re +import shutil +import subprocess +import tempfile + +from plinth import utils +from plinth.actions import privileged +from plinth.db import postgres +from plinth.modules.email.privileged.domain import \ + get_domains as get_email_domains + +DB_HOST = 'localhost' +DB_NAME = 'sogo_fbx' +DB_USER = 'sogo_fbx' +SERVICE_NAME = 'sogo' + +DB_BACKUP_FILE = pathlib.Path('/var/lib/plinth/backups-data/sogo-database.sql') +CONFIG_FILE = pathlib.Path('/etc/sogo/sogo.conf') + + +@privileged +def setup() -> None: + """Setup SOGo database and configuration.""" + database_password = utils.generate_password(16) + postgres.create_database(DB_NAME, DB_USER, database_password) + _create_config(database_password) + + +def _create_config(db_password: str): + """Configure /etc/sogo/sogo.conf""" + try: + domain = _get_config_value('SOGoMailDomain') + if not domain: + # Try to get the domain configured for the email app + domain = get_email_domains()['primary_domain'] + except FileNotFoundError: + domain = 'localhost' + + connection = f'postgresql://{DB_USER}:{db_password}@{DB_HOST}/{DB_NAME}' + + content = f''' +{{ + /* General */ + SOGoMailDomain = "{domain}"; + SOGoLanguage = "English"; + SOGoTimeZone = "UTC"; + SOGoCalendarDefaultRoles = ("PublicViewer", "ConfidentialDAndTViewer"); + SOGoAppointmentSendEMailNotifications = YES; + SOGoRefreshViewCheck = "every_minute"; + + /* Authentication */ + SOGoMaximumFailedLoginCount = "10"; + SOGoMaximumFailedLoginInterval = "300"; + SOGoFailedLoginBlockInterval = "300"; + + /* Database */ + SOGoProfileURL = "{connection}/sogo_user_profile"; + OCSFolderInfoURL = "{connection}/sogo_folder_info"; + OCSSessionsFolderURL = "{connection}/sogo_sessions_folder"; + OCSEMailAlarmsFolderURL = "{connection}/sogo_alarms_folder"; + OCSStoreURL = "{connection}/sogo_store"; + OCSAclURL = "{connection}/sogo_acl"; + OCSCacheFolderURL = "{connection}/sogo_cache_folder"; + OCSAdminURL = "{connection}/sogo_admin"; + + /* Cache */ + SOGoMemcachedHost = "127.0.0.1"; + + /* SMTP */ + SOGoMailingMechanism = "smtp"; + SOGoSMTPServer = "smtp://127.0.0.1:587/?tls=YES&tlsVerifyMode=allowInsecureLocalhost"; + SOGoSMTPAuthenticationType = "PLAIN"; + + /* IMAP */ + SOGoDraftsFolderName = "Drafts"; + SOGoSentFolderName = "Sent"; + SOGoTrashFolderName = "Trash"; + SOGoJunkFolderName = "Junk"; + SOGoIMAPServer = "imap://127.0.0.1:143/?tls=YES&tlsVerifyMode=allowInsecureLocalhost"; + SOGoSieveServer = "sieve://127.0.0.14190/?tls=YES&tlsVerifyMode=allowInsecureLocalhost"; + + /* LDAP */ + SOGoUserSources = ({{ + type = "ldap"; + CNFieldName = "cn"; + IDFieldName = "uid"; + UIDFieldName = "uid"; + baseDN = "ou=users,dc=thisbox"; + canAuthenticate = YES; + displayName = "Shared Addresses"; + hostname = "ldap://127.0.0.1:389"; + id = "directory"; + isAddressBook = YES; + }}); +}}''' # noqa: E501 + CONFIG_FILE.touch(0o640, exist_ok=True) # In case the file does not exist + CONFIG_FILE.chmod(0o640) # In case the file pre-existed + shutil.chown(CONFIG_FILE, 'root', 'sogo') + CONFIG_FILE.write_text(content, encoding='utf-8') + + +@privileged +def dump_database() -> None: + """Dump database to file.""" + postgres.dump_database(DB_BACKUP_FILE, DB_NAME) + + +@privileged +def restore_database() -> None: + """Restore database from file.""" + password = _read_db_password() + postgres.restore_database(DB_BACKUP_FILE, DB_NAME, DB_USER, password) + + +def _read_db_password() -> str: + """Extract the database password from /etc/sogo/sogo.conf using regex""" + pattern = r'postgresql://[^:]+:([^@]+)@localhost' + match = re.search(pattern, _get_config_value('SOGoProfileURL')) + if not match: + raise ValueError('Could not extract password') + + return match.group(1) + + +@privileged +def get_domain() -> str: + """Get the value of SOGoMailDomain from /etc/sogo/sogo.conf""" + return _get_config_value('SOGoMailDomain') + + +@privileged +def set_domain(domain: str): + """Set the value of SOGoMailDomain in /etc/sogo/sogo.conf""" + _set_config_value('SOGoMailDomain', domain) + + +def _get_config_value(key: str) -> str: + """Return the value of a property from the configuration file.""" + process = subprocess.run(['plget', key], input=CONFIG_FILE.read_bytes(), + stdout=subprocess.PIPE, check=True) + return process.stdout.decode().strip() + + +def _set_config_value(key: str, value: str): + """Set the value of a property in the configuration file.""" + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(f'{{\n{key} = "{value}";\n}}'.encode('utf-8')) + temp_file.close() + subprocess.run(['plmerge', CONFIG_FILE, temp_file.name], check=True) + pathlib.Path(temp_file.name).unlink() + + +@privileged +def uninstall() -> None: + """Uninstall SOGo: drop database and configuration files.""" + postgres.drop_database(DB_NAME, DB_USER) + CONFIG_FILE.unlink(missing_ok=True) diff --git a/plinth/modules/sogo/static/icons/sogo.png b/plinth/modules/sogo/static/icons/sogo.png new file mode 100644 index 0000000000000000000000000000000000000000..cbe36365f91b8ec682b3fdb4ec2a91c5ce5e3461 GIT binary patch literal 11932 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

8XqAc7HDrD%t z>mp0l@3}u1FHvKjfwN$<=$`41=r_5KZoB%Fn7qxH(=?n#T(5CxX`OYFnW=9Pi%^13 z%XS1^>-bhF5f@m9C7}Y2qVSzdV)onqLiX{pGS`ei6(Rtjq@!jHw3A>7Ky3X`AyZ>^ z=YYY1llE&)-{1MnC=XrW3X*1}-!&I&Rk0%ak2Y8VwzOl5HrJK6aqX9ZeXL>t@R6V% z-Q4reoA`umfVNd2u(6iq?h)M}#4t|pV(-O>GcR$ofmk>yXje3h9sBeqDy!zAIg{1R z-)U?$G8p26JvH~G!*_jOTm%y^ZjZ#CeWn(C=Q-vb7pm^RH-bHHw-9H&t1*?Xazl-S zLkOTgDDK$E6!m=!kCycFKj&wKXh11eF(F?=8}Tg#7HA%Pyk49P1ir1nj`)%-;Q1eE zC(s!-#-~u@{*HzTKqDNBWdsziejA^gA(Ic;EdMb3Nrk|`0XF%6D{!yInI{y5caZ)h zrqn0Ayd0+@vZEQ_UqD1fIX`h&hfb6mpMPa5Tqq7!VJJ~Ks{z^2^!r{pA!N<0Xy1p5AvVKi^=r+E;8J|g1WDQJ*L=_Z-4IO( z4@gFo$)*2BfCI2rqhBoR4pA@N46p*o6HO?)Wb0s{)&z)33)xO7`d7~QGN8h;K8*JK z-H&nm~#|107;E-W_0RRZTsN!ub&av%J&UNem(bAV6uhl$-Wa4{S-Qym)P>=(9 z4POU^?3yD#Sg|3TwRSC_Mu#|G73&%uXp2CTRTFVKl~LjVKAbDmyo;B#-a;%gAB{{3 zBd9eC{^QVD@Wayp`-PkkR5p+2rb^fjR%Vk2?`gmr zNrNrtdE8O59#yQH_p(Qi^34ghw2BkB^eX$a810wF)j~R2Ib2u#bZ|!CJ(<<+D#T6#B z^zNYeH?J|H)+jviPHpGPVDG})Tui;mXB0$v$#t?T!?`n`c8~CJ-`;|P*rUK}qE> + + + + + + + diff --git a/plinth/modules/sogo/tests/__init__.py b/plinth/modules/sogo/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/sogo/tests/test_functional.py b/plinth/modules/sogo/tests/test_functional.py new file mode 100644 index 000000000..03fdd3436 --- /dev/null +++ b/plinth/modules/sogo/tests/test_functional.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Functional, browser based tests for SOGo app.""" + +import time + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.sogo] + + +class TestSOGoApp(functional.BaseAppTests): + """Basic tests for the SOGo app.""" + app_name = 'sogo' + has_service = True + disable_after_tests = False + + def test_login(self, session_browser): + """Test that login to SOGo interface works.""" + _login(session_browser) + assert functional.eventually(_is_logged_in, [session_browser]) + + +def _login(browser) -> None: + """Login to SOGo web interface.""" + functional.visit(browser, '/SOGo/') + username = functional.config['DEFAULT']['username'] + password = functional.config['DEFAULT']['password'] + functional.eventually(browser.find_by_id, ['input_1']) + time.sleep(1) # For some reason, waiting does not work + browser.find_by_id('input_1').fill(username) + browser.find_by_id('passwordField').fill(password) + submit = browser.find_by_css( + 'form[name=loginForm] button[type=submit]').first + functional.submit(browser, element=submit) + + +def _is_logged_in(browser) -> bool: + """Return whether SOGo login was successful.""" + logout = browser.find_by_css('a[href="../logoff"]') + return bool(logout) diff --git a/plinth/modules/sogo/urls.py b/plinth/modules/sogo/urls.py new file mode 100644 index 000000000..a5152fe7f --- /dev/null +++ b/plinth/modules/sogo/urls.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the SOGo module.""" + +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path(r'^apps/sogo/$', views.SOGoAppView.as_view(), name='index') +] diff --git a/plinth/modules/sogo/views.py b/plinth/modules/sogo/views.py new file mode 100644 index 000000000..34feb45fa --- /dev/null +++ b/plinth/modules/sogo/views.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Views for the SOGo app.""" + +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ + +from plinth.privileged import service as service_privileged +from plinth.views import AppView + +from . import forms, privileged + + +class SOGoAppView(AppView): + """Server configuration page.""" + app_id = 'sogo' + form_class = forms.DomainForm + + def get_initial(self): + """Return the initial values to populate in the form.""" + initial = super().get_initial() + initial['domain'] = privileged.get_domain() + return initial + + def form_valid(self, form): + """Update the settings for changed domain values.""" + old_data = form.initial + new_data = form.cleaned_data + if old_data['domain'] != new_data['domain']: + privileged.set_domain(new_data['domain']) + service_privileged.try_restart('sogo') + service_privileged.try_restart('memcached') + messages.success(self.request, _('Configuration updated')) + + return super().form_valid(form) diff --git a/plinth/tests/functional/__init__.py b/plinth/tests/functional/__init__.py index 578d7593a..dc7cec711 100644 --- a/plinth/tests/functional/__init__.py +++ b/plinth/tests/functional/__init__.py @@ -53,6 +53,7 @@ _site_url = { 'syncthing': '/syncthing/', 'rssbridge': '/rss-bridge/', 'ttrss': '/tt-rss/', + 'sogo': '/SOGo/', } _sys_modules = [