diff --git a/debian/copyright b/debian/copyright
index 582f94643..dcf0cbb39 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -175,6 +175,12 @@ Comment: https://github.com/mumble-voip/mumble/blob/master/icons/mumble.svg
https://github.com/mumble-voip/mumble/blob/master/LICENSE
License: BSD-3-clause
+Files: plinth/modules/nextcloud/static/icons/nextcloud.png
+ plinth/modules/nextcloud/static/icons/nextcloud.svg
+Copyright: 2016 Nextcloud GmbH.
+Comment: https://nextcloud.com/trademarks/
+License: AGPL-3+
+
Files: plinth/modules/openvpn/static/icons/openvpn.png
Copyright: 2017 Kishan Raval
Comment: https://github.com/thekishanraval/Logos
diff --git a/plinth/modules/nextcloud/__init__.py b/plinth/modules/nextcloud/__init__.py
new file mode 100644
index 000000000..c71e80c6b
--- /dev/null
+++ b/plinth/modules/nextcloud/__init__.py
@@ -0,0 +1,123 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""FreedomBox app to configure Nextcloud."""
+
+from django.utils.translation import gettext_lazy as _
+
+from plinth import app as app_module
+from plinth import frontpage, menu
+from plinth.config import DropinConfigs
+from plinth.daemon import Daemon, SharedDaemon
+from plinth.modules.apache.components import Webserver, diagnose_url
+from plinth.modules.firewall.components import (Firewall,
+ FirewallLocalProtection)
+from plinth.package import Packages
+
+from . import manifest, privileged
+
+_description = [
+ _('Nextcloud is a self-hosted productivity platform which provides '
+ 'private and secure functions for file sharing, collaborative work, '
+ 'and more. Nextcloud includes the Nextcloud server, client applications '
+ 'for desktop computers, and mobile clients. The Nextcloud server '
+ 'provides a well integrated web interface.'),
+ _('All users of FreedomBox can use Nextcloud.'),
+ _('To perform administrative actions, use the '
+ f'"{privileged.GUI_ADMIN}" user.'),
+ _('You can set a new password in the "Configuration" section below.'),
+ _('Please note, that Nextcloud isn\'t maintained by Debian, which means '
+ 'security and feature updates are applied independently.')
+]
+
+
+class NextcloudApp(app_module.App):
+ """FreedomBox app for Nextcloud."""
+
+ app_id = 'nextcloud'
+
+ _version = 1
+
+ configure_when_disabled = False
+
+ def __init__(self):
+ """Create components for the app."""
+ super().__init__()
+
+ info = app_module.Info(
+ app_id=self.app_id, version=self._version, name=_('Nextcloud'),
+ icon_filename='nextcloud',
+ short_description=_('File Storage & Collaboration'),
+ description=_description, manual_page='Nextcloud',
+ clients=manifest.clients)
+ self.add(info)
+
+ menu_item = menu.Menu('menu-nextcloud', info.name,
+ info.short_description, info.icon_filename,
+ 'nextcloud:index', parent_url_name='apps')
+ self.add(menu_item)
+
+ shortcut = frontpage.Shortcut('shortcut-nextcloud', info.name,
+ short_description=info.short_description,
+ icon=info.icon_filename,
+ url='/nextcloud/', clients=info.clients,
+ login_required=True)
+ self.add(shortcut)
+
+ packages = Packages('packages-nextcloud',
+ ['podman', 'default-mysql-server'],
+ conflicts=['libpam-tmpdir'],
+ conflicts_action=Packages.ConflictsAction.REMOVE)
+ self.add(packages)
+
+ dropin_configs = DropinConfigs('dropin-configs-nextcloud', [
+ '/etc/apache2/conf-available/nextcloud-freedombox.conf',
+ '/etc/fail2ban/jail.d/nextcloud-freedombox.conf',
+ '/etc/fail2ban/filter.d/nextcloud-freedombox.conf',
+ ])
+ self.add(dropin_configs)
+
+ firewall = Firewall('firewall-nextcloud', info.name,
+ ports=['http', 'https'], is_external=True)
+ self.add(firewall)
+
+ firewall_local_protection = FirewallLocalProtection(
+ 'firewall-local-protection-nextcloud', ['8181'])
+ self.add(firewall_local_protection)
+
+ webserver = Webserver('webserver-nextcloud', 'nextcloud-freedombox',
+ urls=['https://{host}/nextcloud/login'])
+ self.add(webserver)
+
+ daemon = Daemon('daemon-nextcloud', 'nextcloud-fbx')
+ self.add(daemon)
+
+ daemon = Daemon('daemon-nextcloud-timer', 'nextcloud-cron-fbx.timer')
+ self.add(daemon)
+
+ daemon = SharedDaemon('shared-daemon-podman-auto-update',
+ 'podman-auto-update.timer')
+ self.add(daemon)
+
+ daemon = SharedDaemon('shared-daemon-nextcloud-redis', 'redis-server',
+ listen_ports=[(6379, 'tcp4')])
+ self.add(daemon)
+
+ daemon = SharedDaemon('shared-daemon-nextcloud-mysql', 'mysql')
+ self.add(daemon)
+
+ def setup(self, old_version):
+ """Install and configure the app."""
+ super().setup(old_version)
+ privileged.setup()
+ if not old_version:
+ self.enable()
+
+ def uninstall(self):
+ """De-configure and uninstall the app."""
+ privileged.uninstall()
+ super().uninstall()
+
+ def diagnose(self):
+ """Run diagnostics and return the results."""
+ results = super().diagnose()
+ results.append(diagnose_url('docker.com'))
+ return results
diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/etc/apache2/conf-available/nextcloud-freedombox.conf b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/apache2/conf-available/nextcloud-freedombox.conf
new file mode 100644
index 000000000..8d3758f54
--- /dev/null
+++ b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/apache2/conf-available/nextcloud-freedombox.conf
@@ -0,0 +1,25 @@
+##
+## On all sites, provide Nextcloud on a default path: /nextcloud
+##
+## Requires the following Apache modules to be enabled:
+## mod_headers
+## mod_proxy
+## mod_proxy_http
+##
+
+# Redirect .well-known URLs on the server to Nextcloud to enable auto-discovery
+# of calendars, contacts, etc. without having to provide full server URLs. If
+# another app providing similar functionality is enabled, only one of them will
+# work based on the sort order of Apache configuration files.
+Redirect 301 /.well-known/carddav /nextcloud/remote.php/dav
+Redirect 301 /.well-known/caldav /nextcloud/remote.php/dav
+Redirect 301 /.well-known/webfinger /nextcloud/index.php/.well-known/webfinger
+Redirect 301 /.well-known/nodeinfo /nextcloud/index.php/.well-known/nodeinfo
+
+
+ ProxyPass http://127.0.0.1:8181
+
+ ## Send the scheme from user's request to enable Nextcloud to redirect URLs,
+ ## set cookies, set absolute URLs (if any) properly.
+ RequestHeader set X-Forwarded-Proto 'https' env=HTTPS
+
diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/filter.d/nextcloud-freedombox.conf b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/filter.d/nextcloud-freedombox.conf
new file mode 100644
index 000000000..7f2f0ad4b
--- /dev/null
+++ b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/filter.d/nextcloud-freedombox.conf
@@ -0,0 +1,7 @@
+[INCLUDES]
+before = common.conf
+
+[Definition]
+_daemon = apache-access
+prefregex = %(__prefix_line)s
+failregex = \S+ (?::\d+)? - \S+ \[[^\]]*\] "GET /nextcloud/login\?direct=1&user=\S+ HTTP/\S+" 200
diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/jail.d/nextcloud-freedombox.conf b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/jail.d/nextcloud-freedombox.conf
new file mode 100644
index 000000000..a634a6ef9
--- /dev/null
+++ b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/jail.d/nextcloud-freedombox.conf
@@ -0,0 +1,4 @@
+[nextcloud-freedombox]
+enabled = true
+filter = nextcloud-freedombox
+journalmatch = SYSLOG_IDENTIFIER=apache-access
diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/modules-enabled/nextcloud b/plinth/modules/nextcloud/data/usr/share/freedombox/modules-enabled/nextcloud
new file mode 100644
index 000000000..4a1247e87
--- /dev/null
+++ b/plinth/modules/nextcloud/data/usr/share/freedombox/modules-enabled/nextcloud
@@ -0,0 +1 @@
+plinth.modules.nextcloud
diff --git a/plinth/modules/nextcloud/forms.py b/plinth/modules/nextcloud/forms.py
new file mode 100644
index 000000000..d2c0dcb64
--- /dev/null
+++ b/plinth/modules/nextcloud/forms.py
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Nextcloud configuration form."""
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+
+class NextcloudForm(forms.Form):
+ """Nextcloud configuration form."""
+
+ domain = forms.CharField(
+ label=_('Domain'), required=False, help_text=_(
+ 'Examples: "myfreedombox.example.org" or "example.onion".'))
+
+ admin_password = forms.CharField(
+ label=_('Administrator password'), help_text=_(
+ 'Optional. Set a new password for Nextcloud\'s administrator '
+ 'account (nextcloud-admin). The password cannot be a common one '
+ 'and the minimum required length is 10 characters'
+ '. Leave this field blank to keep the current password.'),
+ required=False, widget=forms.PasswordInput, min_length=10)
diff --git a/plinth/modules/nextcloud/manifest.py b/plinth/modules/nextcloud/manifest.py
new file mode 100644
index 000000000..005bea377
--- /dev/null
+++ b/plinth/modules/nextcloud/manifest.py
@@ -0,0 +1,49 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Application manifest for Nextcloud."""
+
+from django.utils.translation import gettext_lazy as _
+
+from plinth.clients import store_url
+
+_nextcloud_android_package_id = 'com.nextcloud.client'
+
+clients = [{
+ 'name': _('Nextcloud'),
+ 'platforms': [{
+ 'type': 'web',
+ 'url': '/nextcloud/'
+ }]
+}, {
+ 'name':
+ _('Nextcloud'),
+ 'platforms': [{
+ 'type': 'download',
+ 'os': 'gnu-linux',
+ 'url': 'https://nextcloud.com/install/#install-clients'
+ }, {
+ 'type': 'download',
+ 'os': 'macos',
+ 'url': 'https://nextcloud.com/install/#install-clients'
+ }, {
+ 'type': 'download',
+ 'os': 'windows',
+ 'url': 'https://nextcloud.com/install/#install-clients'
+ }, {
+ 'type': 'store',
+ 'os': 'ios',
+ 'store_name': 'app-store',
+ 'url': 'https://itunes.apple.com/us/app/nextcloud/id1125420102?mt=8'
+ }, {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'google-play',
+ 'url': store_url('google-play', _nextcloud_android_package_id)
+ }, {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'f-droid',
+ 'url': store_url('f-droid', _nextcloud_android_package_id)
+ }]
+}]
+
+backup = {}
diff --git a/plinth/modules/nextcloud/privileged.py b/plinth/modules/nextcloud/privileged.py
new file mode 100644
index 000000000..ec8ae4a4e
--- /dev/null
+++ b/plinth/modules/nextcloud/privileged.py
@@ -0,0 +1,274 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Configure Nextcloud."""
+
+import os
+import pathlib
+import random
+import string
+import subprocess
+import time
+
+from plinth import action_utils
+from plinth.actions import privileged
+
+NETWORK_NAME = 'nextcloud-fbx'
+BRIDGE_IP = '172.16.16.1'
+CONTAINER_IP = '172.16.16.2'
+CONTAINER_NAME = 'nextcloud-fbx'
+VOLUME_NAME = 'nextcloud-volume-fbx'
+IMAGE_NAME = 'docker.io/library/nextcloud:stable-apache'
+
+DB_HOST = 'localhost'
+DB_NAME = 'nextcloud_fbx'
+DB_USER = 'nextcloud_fbx'
+GUI_ADMIN = 'nextcloud-admin'
+SOCKET_CONFIG_FILE = pathlib.Path('/etc/mysql/mariadb.conf.d/'
+ '99-freedombox.cnf')
+SYSTEMD_LOCATION = '/etc/systemd/system/'
+NEXTCLOUD_CONTAINER_SYSTEMD_FILE = pathlib.Path(
+ f'{SYSTEMD_LOCATION}{CONTAINER_NAME}.service')
+NEXTCLOUD_CRON_SERVICE_FILE = pathlib.Path(
+ f'{SYSTEMD_LOCATION}nextcloud-cron-fbx.service')
+NEXTCLOUD_CRON_TIMER_FILE = pathlib.Path(
+ f'{SYSTEMD_LOCATION}nextcloud-cron-fbx.timer')
+
+
+@privileged
+def setup():
+ """Setup Nextcloud configuration."""
+ database_password = _generate_secret_key(16)
+ administrator_password = _generate_secret_key(16)
+ _configure_db_socket()
+ _configure_firewall(action='add', interface_name=NETWORK_NAME)
+ _create_database(database_password)
+ action_utils.podman_run(
+ network_name=NETWORK_NAME, subnet='172.16.16.0/24',
+ bridge_ip=BRIDGE_IP, host_port='8181', container_port='80',
+ container_ip=CONTAINER_IP, volume_name=VOLUME_NAME,
+ container_name=CONTAINER_NAME, image_name=IMAGE_NAME,
+ extra_run_options=[
+ '--env=TRUSTED_PROXIES={BRIDGE_IP}',
+ '--env=OVERWRITEWEBROOT=/nextcloud'
+ ])
+ # OCC isn't immediately available after the container is spun up.
+ # Wait until CAN_INSTALL file is available.
+ timeout = 300
+ while timeout > 0:
+ if os.path.exists('/var/lib/containers/storage/volumes/'
+ 'nextcloud-volume-fbx/_data/config/CAN_INSTALL'):
+ break
+ timeout = timeout - 1
+ time.sleep(1)
+
+ _nextcloud_setup_wizard(database_password, administrator_password)
+ # Check if LDAP has already been configured. This is necessary because
+ # if the setup proccess is rerun when updating the FredomBox app another
+ # redundant LDAP config would be created.
+ is_ldap_configured = _run_occ('ldap:test-config', 's01',
+ capture_output=True)
+ if is_ldap_configured != ('The configuration is valid and the connection '
+ 'could be established!'):
+ _configure_ldap()
+
+ _configure_systemd()
+
+
+def _run_occ(*args, capture_output: bool = False):
+ """Run the Nextcloud occ command inside the container."""
+ occ = [
+ 'podman', 'exec', '--user', 'www-data', CONTAINER_NAME, 'php', 'occ'
+ ] + list(args)
+ return subprocess.run(occ, capture_output=capture_output, check=False)
+
+
+@privileged
+def get_domain():
+ """Return domain name set in Nextcloud."""
+ try:
+ domain = _run_occ('config:system:get', 'overwritehost',
+ capture_output=True)
+ return domain.stdout.decode().strip()
+ except subprocess.CalledProcessError:
+ return None
+
+
+@privileged
+def set_domain(domain_name: str):
+ """Set Nextcloud domain name."""
+ protocol = 'https'
+ if domain_name.endswith('.onion'):
+ protocol = 'http'
+
+ if domain_name:
+ _run_occ('config:system:set', 'overwritehost', '--value', domain_name)
+
+ _run_occ('config:system:set', 'overwrite.cli.url', '--value',
+ f'{protocol}://{domain_name}/nextcloud')
+
+ _run_occ('config:system:set', 'overwriteprotocol', '--value', protocol)
+
+ # Restart to apply changes immediately
+ action_utils.service_restart('nextcloud-fbx')
+
+
+@privileged
+def set_admin_password(password: str):
+ """Set password for owncloud-admin"""
+ subprocess.run([
+ 'podman', 'exec', '--user', 'www-data', f'--env=OC_PASS={password}',
+ '-it', CONTAINER_NAME, 'sh', '-c',
+ ("/var/www/html/occ "
+ f"user:resetpassword --password-from-env {GUI_ADMIN}")
+ ], check=True)
+
+
+def _configure_firewall(action, interface_name):
+ subprocess.run([
+ 'firewall-cmd', '--permanent', '--zone=trusted',
+ f'--{action}-interface={interface_name}'
+ ], check=True)
+ action_utils.service_restart('firewalld')
+
+
+def _configure_db_socket():
+ file_content = f'''## This file is automatically generated by FreedomBox
+## Enable database to create a socket for podman's bridge network
+[mysqld]
+bind-address = {BRIDGE_IP}
+'''
+ SOCKET_CONFIG_FILE.write_text(file_content, encoding='utf-8')
+ action_utils.service_restart('mariadb')
+
+
+def _create_database(db_password):
+ """Create an empty MySQL database for Nextcloud."""
+ # SQL injection is avoided due to known input.
+ _db_file_path = pathlib.Path('/var/lib/mysql/nextcloud_fbx')
+ if _db_file_path.exists():
+ return
+ query = f'''CREATE USER '{DB_USER}'@'{CONTAINER_IP}'
+IDENTIFIED BY'{db_password}';
+CREATE DATABASE {DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+GRANT ALL PRIVILEGES ON {DB_NAME}.* TO '{DB_USER}'@'{CONTAINER_IP}';
+FLUSH PRIVILEGES;'''
+ subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
+ check=True)
+
+
+def _nextcloud_setup_wizard(db_password, admin_password):
+ admin_data_dir = pathlib.Path(
+ '/var/lib/containers/storage/volumes/nextcloud-volume-fbx/'
+ f'_data/data/{GUI_ADMIN}')
+ if not admin_data_dir.exists():
+ _run_occ('maintenance:install', '--database=mysql',
+ f'--database-name={DB_NAME}', f'--database-host={BRIDGE_IP}',
+ '--database-port=3306', f'--database-user={DB_USER}',
+ f'--database-pass={db_password}', f'--admin-user={GUI_ADMIN}',
+ f'--admin-pass={admin_password}')
+ # For the server to work properly, it's important to configure background
+ # jobs correctly. Cron is the recommended setting.
+ _run_occ('background:cron')
+
+
+def _configure_ldap():
+ _run_occ('app:enable', 'user_ldap')
+ _run_occ('ldap:create-empty-config')
+
+ ldap_settings = {
+ 'ldapBase': 'dc=thisbox',
+ 'ldapBaseGroups': 'dc=thisbox',
+ 'ldapBaseUsers': 'dc=thisbox',
+ 'ldapConfigurationActive': '1',
+ 'ldapGroupDisplayName': 'cn',
+ 'ldapGroupFilter': '(&(|(objectclass=posixGroup)))',
+ 'ldapGroupFilterMode': '0',
+ 'ldapGroupFilterObjectclass': 'posixGroup',
+ 'ldapGroupMemberAssocAttr': 'memberUid',
+ 'ldapHost': BRIDGE_IP,
+ 'ldapLoginFilter': '(&(|(objectclass=posixAccount))(uid=%uid))',
+ 'ldapLoginFilterEmail': '0',
+ 'ldapLoginFilterMode': '0',
+ 'ldapLoginFilterUsername': '1',
+ 'ldapNestedGroups': '0',
+ 'ldapPort': '389',
+ 'ldapTLS': '0',
+ 'ldapUserDisplayName': 'cn',
+ 'ldapUserFilter': '(|(objectclass=posixAccount))',
+ 'ldapUserFilterMode': '0',
+ 'ldapUserFilterObjectclass': 'account',
+ 'ldapUuidGroupAttribute': 'auto',
+ 'ldapUuidUserAttribute': 'auto',
+ 'turnOffCertCheck': '0',
+ 'turnOnPasswordChange': '0',
+ 'useMemberOfToDetectMembership': '0'
+ }
+
+ for key, value in ldap_settings.items():
+ _run_occ('ldap:set-config', 's01', key, value)
+
+
+def _configure_systemd():
+ systemd_content = subprocess.run(
+ ['podman', 'generate', 'systemd', '--new', CONTAINER_NAME],
+ capture_output=True, check=True).stdout.decode()
+ # Create service and timer for running periodic php jobs.
+ NEXTCLOUD_CONTAINER_SYSTEMD_FILE.write_text(systemd_content,
+ encoding='utf-8')
+ nextcloud_cron_service_content = '''
+[Unit]
+Description=Nextcloud cron.php job
+
+[Service]
+ExecCondition=/usr/bin/podman exec --user www-data nextcloud-fbx php occ status -e
+ExecStart=/usr/bin/podman exec --user www-data nextcloud-fbx php /var/www/html/cron.php
+KillMode=process
+''' # noqa: E501
+ nextcloud_cron_timer_content = '''[Unit]
+Description=Run Nextcloud cron.php every 5 minutes
+
+[Timer]
+OnBootSec=5min
+OnUnitActiveSec=5min
+Unit=nextcloud-cron-fbx.service
+
+[Install]
+WantedBy=timers.target
+'''
+ NEXTCLOUD_CRON_SERVICE_FILE.write_text(nextcloud_cron_service_content)
+ NEXTCLOUD_CRON_TIMER_FILE.write_text(nextcloud_cron_timer_content)
+ action_utils.service_daemon_reload()
+
+
+@privileged
+def uninstall():
+ """Uninstall Nextcloud"""
+ _drop_database()
+ _remove_db_socket()
+ _configure_firewall(action='remove', interface_name=NETWORK_NAME)
+ action_utils.podman_uninstall(container_name=CONTAINER_NAME,
+ network_name=NETWORK_NAME,
+ volume_name=VOLUME_NAME,
+ image_name=IMAGE_NAME)
+ files = [NEXTCLOUD_CRON_SERVICE_FILE, NEXTCLOUD_CRON_TIMER_FILE]
+ for file in files:
+ file.unlink(missing_ok=True)
+
+
+def _remove_db_socket():
+ SOCKET_CONFIG_FILE.unlink(missing_ok=True)
+ action_utils.service_restart('mariadb')
+
+
+def _drop_database():
+ """Drop the mysql database that was created during install."""
+ query = f'''DROP DATABASE {DB_NAME};
+DROP User '{DB_USER}'@'{CONTAINER_IP}';'''
+ subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
+ check=True)
+
+
+def _generate_secret_key(length=64, chars=None):
+ """Generate a new random secret key for use with Nextcloud."""
+ chars = chars or (string.ascii_letters + string.digits)
+ rand = random.SystemRandom()
+ return ''.join(rand.choice(chars) for _ in range(length))
diff --git a/plinth/modules/nextcloud/static/icons/nextcloud.png b/plinth/modules/nextcloud/static/icons/nextcloud.png
new file mode 100644
index 000000000..27eadda87
Binary files /dev/null and b/plinth/modules/nextcloud/static/icons/nextcloud.png differ
diff --git a/plinth/modules/nextcloud/static/icons/nextcloud.svg b/plinth/modules/nextcloud/static/icons/nextcloud.svg
new file mode 100644
index 000000000..a04c5763b
--- /dev/null
+++ b/plinth/modules/nextcloud/static/icons/nextcloud.svg
@@ -0,0 +1,55 @@
+
+
diff --git a/plinth/modules/nextcloud/urls.py b/plinth/modules/nextcloud/urls.py
new file mode 100644
index 000000000..1132e071f
--- /dev/null
+++ b/plinth/modules/nextcloud/urls.py
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""URLs for the Nextcloud module."""
+
+from django.urls import re_path
+
+from .views import NextcloudAppView
+
+urlpatterns = [
+ re_path(r'^apps/nextcloud/$', NextcloudAppView.as_view(), name='index')
+]
diff --git a/plinth/modules/nextcloud/views.py b/plinth/modules/nextcloud/views.py
new file mode 100644
index 000000000..aed885bce
--- /dev/null
+++ b/plinth/modules/nextcloud/views.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Django views for Nextcloud app."""
+
+import logging
+
+from django.contrib import messages
+from django.utils.translation import gettext_lazy as _
+
+from plinth.modules.nextcloud.forms import NextcloudForm
+from plinth.views import AppView
+
+from . import privileged
+
+logger = logging.getLogger(__name__)
+
+
+class NextcloudAppView(AppView):
+ """Show Nextcloud app main view."""
+
+ app_id = 'nextcloud'
+ form_class = NextcloudForm
+
+ def get_initial(self):
+ """Return the values to fill in the form."""
+ initial = super().get_initial()
+ initial.update({'domain': privileged.get_domain()})
+ return initial
+
+ def form_valid(self, form):
+ """Apply the changes submitted in the form."""
+ old_config = self.get_initial()
+ new_config = form.cleaned_data
+
+ is_changed = False
+
+ def _value_changed(key):
+ return old_config.get(key) != new_config.get(key)
+
+ if _value_changed('domain'):
+ privileged.set_domain(new_config['domain'])
+ is_changed = True
+
+ if new_config['admin_password']:
+ try:
+ privileged.set_admin_password(new_config['admin_password'])
+ is_changed = True
+ except Exception:
+ messages.error(
+ self.request,
+ _('Password update failed. Please choose a stronger '
+ 'password.'))
+ if is_changed:
+ messages.success(self.request, _('Configuration updated.'))
+
+ return super().form_valid(form)