diff --git a/actions/wordpress b/actions/wordpress
new file mode 100755
index 000000000..ee2afe868
--- /dev/null
+++ b/actions/wordpress
@@ -0,0 +1,189 @@
+#!/usr/bin/python3
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Configuration helper for WordPress.
+"""
+
+import argparse
+import os
+import pathlib
+import random
+import shutil
+import string
+import subprocess
+
+import augeas
+from plinth import action_utils
+from plinth.modules.wordpress import PUBLIC_ACCESS_FILE
+
+_config_file_path = pathlib.Path('/etc/wordpress/config-default.php')
+_db_file_path = pathlib.Path('/etc/wordpress/database.php')
+_db_backup_file = pathlib.Path(
+ '/var/lib/plinth/backups-data/wordpress-database.sql')
+DB_HOST = 'localhost'
+DB_NAME = 'wordpress_fbx'
+DB_USER = 'wordpress_fbx'
+
+
+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='Create initial configuration and database')
+ subparsers.add_parser('dump-database', help='Dump database to file')
+ subparsers.add_parser('restore-database',
+ help='Restore database from file')
+ subparser = subparsers.add_parser('set-public',
+ help='Allow/disallow public access')
+ subparser.add_argument('--enable', choices=('True', 'False'),
+ help='Whether to enable or disable public acceess')
+
+ subparsers.required = True
+ return parser.parse_args()
+
+
+def subcommand_setup(_):
+ """Create initial configuration and database for WordPress."""
+ if _db_file_path.exists() or _config_file_path.exists():
+ return
+
+ db_password = _generate_secret_key(16)
+
+ _create_config_file(DB_HOST, DB_NAME, DB_USER, db_password)
+ _create_database(DB_NAME)
+ _set_privileges(DB_HOST, DB_NAME, DB_USER, db_password)
+
+
+def _create_config_file(db_host, db_name, db_user, db_password):
+ """Create a PHP configuration file included by WordPress."""
+ secret_keys = [_generate_secret_key() for _ in range(8)]
+
+ config_contents = f'''~`+=,.:/?|')
+ rand = random.SystemRandom()
+ return ''.join(rand.choice(chars) for _ in range(length))
+
+
+def subcommand_set_public(arguments):
+ """Allow/disallow public access."""
+ public_access_file = pathlib.Path(PUBLIC_ACCESS_FILE)
+ if arguments.enable == 'True':
+ public_access_file.touch()
+ else:
+ public_access_file.unlink(missing_ok=True)
+
+ action_utils.service_reload('apache2')
+
+
+def subcommand_dump_database(_):
+ """Dump database to file."""
+ _db_backup_file.parent.mkdir(parents=True, exist_ok=True)
+ with _db_backup_file.open('w') as file_handle:
+ subprocess.run([
+ 'mysqldump', '--add-drop-database', '--add-drop-table',
+ '--add-drop-trigger', '--user', 'root', '--databases', DB_NAME
+ ], stdout=file_handle, check=True)
+
+
+def subcommand_restore_database(_):
+ """Restore database from file."""
+ with _db_backup_file.open('r') as file_handle:
+ subprocess.run(['mysql', '--user', 'root'], stdin=file_handle,
+ check=True)
+
+ _set_privileges(DB_HOST, DB_NAME, DB_USER, _read_db_password())
+
+
+def _read_db_password():
+ """Return the password stored in the DB configuration file."""
+ aug = _load_augeas()
+ return aug.get('./$dbpass').strip('\'"')
+
+
+def _load_augeas():
+ """Initialize augeas."""
+ aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
+ augeas.Augeas.NO_MODL_AUTOLOAD)
+ aug.transform('Phpvars', str(_db_file_path))
+ aug.set('/augeas/context', '/files' + str(_db_file_path))
+ aug.load()
+
+ return aug
+
+
+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 6d594ce10..dc737e4d1 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -278,6 +278,12 @@ Copyright: 2005 Andrew Dolgov
Comment: https://git.tt-rss.org/fox/tt-rss/src/master/images/favicon-72px.png
License: GPL-3+
+Files: static/themes/default/icons/wordpress.png
+ static/themes/default/icons/wordpress.svg
+Copyright: 2011-2021 WordPress Contributors
+Comment: https://github.com/WordPress/wordpress-develop/blob/master/src/wp-admin/images/wordpress-logo.svg
+License: GPL-2+
+
Files: static/themes/default/icons/windows.png
static/themes/default/icons/windows.svg
Copyright: 2007 ruli (https://thenounproject.com/2007ruli/)
diff --git a/plinth/modules/wordpress/__init__.py b/plinth/modules/wordpress/__init__.py
new file mode 100644
index 000000000..35f64de57
--- /dev/null
+++ b/plinth/modules/wordpress/__init__.py
@@ -0,0 +1,119 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+FreedomBox app to configure WordPress.
+"""
+
+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.daemon import Daemon
+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
+
+PUBLIC_ACCESS_FILE = '/etc/wordpress/is_public'
+
+version = 1
+
+managed_services = ['wordpress-freedombox.timer']
+
+# Add php to avoid wordpress package bringing in lib-apache2-mod-php.
+# WordPress only supports MySQL/MariaDB as database server.
+managed_packages = [
+ 'wordpress',
+ 'php', # Avoid WordPress package bringing in libapache2-mod-php
+ 'php-imagick', # Optional, for performance
+ 'php-ssh2', # Optional, to upload plugins/themes using SSH connection
+ 'php-zip', # Optional, for performance
+ 'default-mysql-server', # WordPress only supports MySQL/MariaDB as DB
+]
+
+_description = [
+ _('WordPress is a popular way to create and manage websites and blogs. '
+ 'Content can be managed using a visual interface. Layout and '
+ 'functionality of the web pages can be customized. Appearance can be '
+ 'chosen using themes. Administration interface and produced web pages '
+ 'are suitable for mobile devices.'),
+ format_lazy(
+ _('You need to run WordPress setup by visiting the app before making '
+ 'the site publicly available below. Setup must be run when '
+ 'accessing {box_name} with the correct domain name. Enable '
+ 'permalinks in administrator interface for better URLs to your '
+ 'pages and posts.'), box_name=_(cfg.box_name)),
+ _('WordPress has its own user accounts. First administrator account is '
+ 'created during setup. Bookmark the admin page to reach administration '
+ 'interface in the future.'),
+ _('After a major version upgrade, you need to manually run database '
+ 'upgrade from administrator interface. Additional plugins or themes may '
+ 'be installed and upgraded at your own risk.'),
+]
+
+app = None
+
+
+class WordPressApp(app_module.App):
+ """FreedomBox app for WordPress."""
+
+ app_id = 'wordpress'
+
+ def __init__(self):
+ """Create components for the app."""
+ super().__init__()
+
+ info = app_module.Info(
+ app_id=self.app_id, version=version, name=_('WordPress'),
+ icon_filename='wordpress', short_description=_('Website and Blog'),
+ description=_description, manual_page='WordPress',
+ clients=manifest.clients,
+ donation_url='https://wordpressfoundation.org/donate/')
+ self.add(info)
+
+ menu_item = menu.Menu('menu-wordpress', info.name,
+ info.short_description, info.icon_filename,
+ 'wordpress:index', parent_url_name='apps')
+ self.add(menu_item)
+
+ shortcut = frontpage.Shortcut('shortcut-wordpress', info.name,
+ short_description=info.short_description,
+ icon=info.icon_filename,
+ url='/wordpress/', clients=info.clients)
+ self.add(shortcut)
+
+ firewall = Firewall('firewall-wordpress', info.name,
+ ports=['http', 'https'], is_external=True)
+ self.add(firewall)
+
+ webserver = Webserver('webserver-wordpress', 'wordpress-freedombox',
+ urls=['https://{host}/wordpress/'])
+ self.add(webserver)
+
+ daemon = Daemon('daemon-wordpress', managed_services[0])
+ self.add(daemon)
+
+ backup_restore = WordPressBackupRestore('backup-restore-wordpress',
+ **manifest.backup)
+ self.add(backup_restore)
+
+
+class WordPressBackupRestore(BackupRestore):
+ """Component to backup/restore WordPress."""
+
+ def backup_pre(self, packet):
+ """Save database contents."""
+ actions.superuser_run('wordpress', ['dump-database'])
+
+ def restore_post(self, packet):
+ """Restore database contents."""
+ actions.superuser_run('wordpress', ['restore-database'])
+
+
+def setup(helper, old_version=None):
+ """Install and configure the module."""
+ helper.install(managed_packages)
+ helper.call('post', actions.superuser_run, 'wordpress', ['setup'])
+ helper.call('post', app.enable)
diff --git a/plinth/modules/wordpress/data/etc/apache2/conf-available/wordpress-freedombox.conf b/plinth/modules/wordpress/data/etc/apache2/conf-available/wordpress-freedombox.conf
new file mode 100644
index 000000000..a55211a21
--- /dev/null
+++ b/plinth/modules/wordpress/data/etc/apache2/conf-available/wordpress-freedombox.conf
@@ -0,0 +1,56 @@
+##
+## On all sites, provide WordPress on a default path: /wordpress
+##
+## Requires the following Apache modules to be enabled:
+## mod_alias
+## mod_rewrite
+## mod_proxy_fcgi
+## mod_auth_pubtkt
+##
+
+# Match longer aliases first to meet expectations
+Alias /wordpress/wp-content /var/lib/wordpress/wp-content
+Alias /wordpress /usr/share/wordpress
+
+
+ Options FollowSymLinks
+
+
+ RewriteEngine On
+ RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+ RewriteBase /wordpress/
+ RewriteRule ^index\.php$ - [L]
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+ RewriteRule . /wordpress/index.php [L]
+
+
+ # Allow access only if site is marked as public or if user is an admin
+
+ Include includes/freedombox-single-sign-on.conf
+
+ TKTAuthToken "admin"
+
+
+
+ # Increase maximum upload file size
+
+ ProxyFCGISetEnvIf true PHP_VALUE "post_max_size=128M \n upload_max_filesize = 128M"
+
+
+
+
+ Options FollowSymLinks
+
+ # Allow access only if site is marked as public or if user is an admin
+
+ Include includes/freedombox-single-sign-on.conf
+
+ TKTAuthToken "admin"
+
+
+
+
+ Require all granted
+
+
diff --git a/plinth/modules/wordpress/data/etc/plinth/modules-enabled/wordpress b/plinth/modules/wordpress/data/etc/plinth/modules-enabled/wordpress
new file mode 100644
index 000000000..ca1bf6533
--- /dev/null
+++ b/plinth/modules/wordpress/data/etc/plinth/modules-enabled/wordpress
@@ -0,0 +1 @@
+plinth.modules.wordpress
diff --git a/plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.service b/plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.service
new file mode 100644
index 000000000..c25e0ac2a
--- /dev/null
+++ b/plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.service
@@ -0,0 +1,37 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[Unit]
+Description=WordPress Scheduled Events Trigger (Cron)
+Documentation=https://rtcamp.com/tutorials/wordpress/wp-cron-crontab/
+
+[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_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
+DevicePolicy=closed
+ExecStart=php --file /usr/share/wordpress/wp-cron.php
+Group=www-data
+LockPersonality=yes
+NoNewPrivileges=yes
+PrivateDevices=yes
+PrivateMounts=yes
+PrivateTmp=yes
+PrivateUsers=yes
+ProtectControlGroups=yes
+ProtectClock=yes
+ProtectHome=yes
+ProtectHostname=yes
+ProtectKernelLogs=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+ProtectSystem=strict
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
+RestrictNamespaces=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+StateDirectory=wordpress/wp-content
+SystemCallArchitectures=native
+SystemCallFilter=@system-service
+SystemCallFilter=~@resources
+SystemCallFilter=~@privileged
+SystemCallErrorNumber=EPERM
+Type=simple
+User=www-data
diff --git a/plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.timer b/plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.timer
new file mode 100644
index 000000000..0a42dbb79
--- /dev/null
+++ b/plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.timer
@@ -0,0 +1,11 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[Unit]
+Description=WordPress Scheduled Events Trigger (Cron) Timer
+Documentation=https://rtcamp.com/tutorials/wordpress/wp-cron-crontab/
+
+[Timer]
+OnCalendar=*:0/10
+
+[Install]
+WantedBy=timers.target
diff --git a/plinth/modules/wordpress/forms.py b/plinth/modules/wordpress/forms.py
new file mode 100644
index 000000000..8c6fe3479
--- /dev/null
+++ b/plinth/modules/wordpress/forms.py
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+FreedomBox app for configuring WordPress.
+"""
+
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+
+
+class WordPressForm(forms.Form):
+ """WordPress configuration form"""
+
+ is_public = forms.BooleanField(
+ label=_('Public access'), required=False, help_text=_(
+ 'Allow all visitors. Disabling allows only administrators to view '
+ 'the WordPress site or blog. Enable only after performing initial '
+ 'WordPress setup.'))
diff --git a/plinth/modules/wordpress/manifest.py b/plinth/modules/wordpress/manifest.py
new file mode 100644
index 000000000..dd16bc2bd
--- /dev/null
+++ b/plinth/modules/wordpress/manifest.py
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from django.utils.translation import ugettext_lazy as _
+
+clients = [{
+ 'name': _('WordPress'),
+ 'platforms': [{
+ 'type': 'web',
+ 'url': '/wordpress/'
+ }]
+}]
+
+backup = {
+ 'data': {
+ 'files': ['/var/lib/plinth/backups-data/wordpress-database.sql'],
+ 'directories': ['/var/lib/wordpress/']
+ },
+ 'secrets': {
+ 'directories': ['/etc/wordpress/']
+ },
+}
diff --git a/plinth/modules/wordpress/tests/__init__.py b/plinth/modules/wordpress/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plinth/modules/wordpress/tests/test_functional.py b/plinth/modules/wordpress/tests/test_functional.py
new file mode 100644
index 000000000..4e33c2e8a
--- /dev/null
+++ b/plinth/modules/wordpress/tests/test_functional.py
@@ -0,0 +1,167 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Functional, browser based tests for WordPress.
+"""
+
+import pytest
+from plinth.tests import functional
+
+
+@pytest.fixture(scope='module', autouse=True)
+def fixture_background(session_browser):
+ """Login and install the app."""
+ functional.login(session_browser)
+ functional.install(session_browser, 'wordpress')
+ yield
+ functional.app_disable(session_browser, 'wordpress')
+
+
+def test_enable_disable(session_browser):
+ """Test enabling the app."""
+ functional.app_disable(session_browser, 'wordpress')
+
+ functional.app_enable(session_browser, 'wordpress')
+ assert functional.service_is_running(session_browser, 'wordpress')
+ assert functional.is_available(session_browser, 'wordpress')
+
+ functional.app_disable(session_browser, 'wordpress')
+ assert functional.service_is_not_running(session_browser, 'wordpress')
+ assert not functional.is_available(session_browser, 'wordpress')
+
+
+def test_post(session_browser):
+ """Test writing a blog post."""
+ functional.app_enable(session_browser, 'wordpress')
+ _write_post(session_browser, 'FunctionalTest')
+ assert _get_post(session_browser, 'FunctionalTest')
+ _delete_post(session_browser, 'FunctionalTest')
+ assert not _get_post(session_browser, 'FunctionalTest')
+
+
+def test_public_mode(session_browser):
+ """Test that site is available without login in public mode."""
+ functional.app_enable(session_browser, 'wordpress')
+ _enable_public_mode(session_browser, True)
+
+ def no_login_prompt():
+ _load_site(session_browser)
+ return not functional.is_login_prompt(session_browser)
+
+ try:
+ functional.logout(session_browser)
+ functional.eventually(no_login_prompt)
+ finally:
+ functional.login(session_browser)
+
+
+def test_private_mode(session_browser):
+ """Test that site is not available without login in public mode."""
+ functional.app_enable(session_browser, 'wordpress')
+ _enable_public_mode(session_browser, False)
+
+ def login_prompt():
+ _load_site(session_browser)
+ return functional.is_login_prompt(session_browser)
+
+ try:
+ functional.logout(session_browser)
+ functional.eventually(login_prompt)
+ finally:
+ functional.login(session_browser)
+
+
+@pytest.mark.backups
+def test_backup(session_browser):
+ """Test backing up and restoring."""
+ functional.app_enable(session_browser, 'wordpress')
+ _write_post(session_browser, 'FunctionalTest')
+ functional.backup_create(session_browser, 'wordpress', 'test_wordpress')
+ _delete_post(session_browser, 'FunctionalTest')
+ functional.backup_restore(session_browser, 'wordpress', 'test_wordpress')
+ assert _get_post(session_browser, 'FunctionalTest')
+
+
+def _load_site(browser):
+ """Visit WordPress site and wait until becomes available."""
+ functional.visit(browser, '/wordpress/wp-admin/')
+
+ def loaded():
+ browser.reload()
+ title_node = browser.find_by_css('title')
+ return (not title_node or '404' not in title_node[0].text)
+
+ functional.eventually(loaded)
+
+
+def _visit_site(browser):
+ """Visit WordPress and run the first setup wizard if needed."""
+ _load_site(browser)
+ if '/install.php' in browser.url:
+ browser.fill('weblog_title', 'Test Blog')
+ browser.fill('user_name', functional.config['DEFAULT']['username'])
+ # browser.fill() once does not work for some reason for password field
+ browser.fill('admin_password',
+ functional.config['DEFAULT']['password'])
+ browser.fill('admin_password',
+ functional.config['DEFAULT']['password'])
+ browser.check('pw_weak')
+ browser.fill('admin_email', 'admin@example.org')
+ functional.submit(browser)
+
+ if not browser.find_by_css('.install-success'):
+ raise Exception('WordPress installation failed')
+
+ functional.visit(browser, '/wordpress/wp-admin/')
+
+ if not browser.find_by_id('wpadminbar'):
+ functional.visit(browser, '/wordpress/wp-login.php')
+ browser.fill('log', functional.config['DEFAULT']['username'])
+ browser.fill('pwd', functional.config['DEFAULT']['password'])
+ functional.submit(browser)
+
+
+def _write_post(browser, title):
+ """Create a blog post in WordPress site."""
+ post = _get_post(browser, title)
+ if post:
+ _delete_post(browser, title)
+
+ functional.visit(browser, '/wordpress/wp-admin/post-new.php')
+ if browser.find_by_css('.edit-post-welcome-guide'):
+ browser.find_by_css('.components-modal__header button')[0].click()
+
+ browser.find_by_id('post-title-0').fill(title)
+ browser.find_by_css('.editor-post-publish-button__button')[0].click()
+ functional.eventually(browser.find_by_css, ['.editor-post-publish-button'])
+ browser.find_by_css('.editor-post-publish-button')[0].click()
+
+
+def _delete_post(browser, title):
+ """Delete a blog post in WordPress site."""
+ post = _get_post(browser, title)
+ if not post:
+ raise Exception('Post not found')
+
+ delete_element = post.find_by_css('.submitdelete')[0]
+ browser.visit(delete_element['href'])
+
+
+def _get_post(browser, title):
+ """Return whether a blog post with a given title is available."""
+ _visit_site(browser)
+ functional.visit(browser, '/wordpress/wp-admin/edit.php')
+ xpath = '//tr[contains(@class, "type-post")][.//a[contains(@class, ' \
+ f'"row-title") and contains(text(), "{title}")]]'
+ post = browser.find_by_xpath(xpath)
+ return post[0] if post else None
+
+
+def _enable_public_mode(browser, should_enable):
+ """Enable/disable the public mode."""
+ checkbox = browser.find_by_id('id_is_public')
+ if should_enable:
+ checkbox.check()
+ else:
+ checkbox.uncheck()
+
+ functional.submit(browser, form_class='form-configuration')
diff --git a/plinth/modules/wordpress/urls.py b/plinth/modules/wordpress/urls.py
new file mode 100644
index 000000000..f46b63908
--- /dev/null
+++ b/plinth/modules/wordpress/urls.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+URLs for the WordPress module.
+"""
+
+from django.conf.urls import url
+
+from .views import WordPressAppView
+
+urlpatterns = [
+ url(r'^apps/wordpress/$', WordPressAppView.as_view(app_id='wordpress'),
+ name='index'),
+]
diff --git a/plinth/modules/wordpress/views.py b/plinth/modules/wordpress/views.py
new file mode 100644
index 000000000..ef341faf3
--- /dev/null
+++ b/plinth/modules/wordpress/views.py
@@ -0,0 +1,38 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+FreedomBox app for configuring WordPress.
+"""
+
+import pathlib
+
+from django.contrib import messages
+from django.utils.translation import ugettext as _
+from plinth import actions, views
+
+from . import PUBLIC_ACCESS_FILE
+from .forms import WordPressForm
+
+
+class WordPressAppView(views.AppView):
+ """Serve configuration page."""
+ form_class = WordPressForm
+ app_id = 'wordpress'
+
+ def get_initial(self):
+ """Get the current WordPress settings."""
+ status = super().get_initial()
+ status['is_public'] = pathlib.Path(PUBLIC_ACCESS_FILE).exists()
+ 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['is_public'] != new_status['is_public']:
+ actions.superuser_run(
+ 'wordpress',
+ ['set-public', '--enable',
+ str(new_status['is_public'])])
+ messages.success(self.request, _('Configuration updated'))
+
+ return super().form_valid(form)
diff --git a/static/themes/default/icons/wordpress.png b/static/themes/default/icons/wordpress.png
new file mode 100644
index 000000000..7082d0f50
Binary files /dev/null and b/static/themes/default/icons/wordpress.png differ
diff --git a/static/themes/default/icons/wordpress.svg b/static/themes/default/icons/wordpress.svg
new file mode 100644
index 000000000..da838f6b9
--- /dev/null
+++ b/static/themes/default/icons/wordpress.svg
@@ -0,0 +1,54 @@
+
+