From efa615201b827a605554166a873804e5f687b00e Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Wed, 28 Jul 2021 18:37:04 -0700 Subject: [PATCH] wordpress: New app to manage a WordPress site/blog - Use php-fpm instead of using mod-php. - Create database and setup permissions manually. Tables and initial data are created during the initial setup process done by WordPress. Database upgrades are handled by WordPress. Minor versions are upgraded automatically and major version need user intervention. - Backup/restore functionality including database. - Install recommended extensions for performance. - Setup and run cron jobs to ensure that scheduled publications are completed (among other things). Service has systemd security features. Timer is set to run every 10 minutes. - Functional tests for adding/removing posts and backup/restore. - Increase file upload size limit to 128MiB. - A private mode (default) for keeping the setup process secure. Should be disabled after first setup is completed. This uses a new approach using file-based flag for different Apache configurations. TODO: - Find a nice way to allow WordPress to upload plugins/themes. Currently this operation files and users are expected to manually scp the files to /var/lib/wordpress/wp-content/{plugins,themes} directory. Tests: - Functional tests. - Schedule publishing of a post. Notice that post got published. - Test uploading a file larger than 2MiB. - Test enabling permalinks. This leads to nicer looking URLs. - Test adding images to posts/pages. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- actions/wordpress | 189 ++++++++++++++++++ debian/copyright | 6 + plinth/modules/wordpress/__init__.py | 119 +++++++++++ .../conf-available/wordpress-freedombox.conf | 56 ++++++ .../data/etc/plinth/modules-enabled/wordpress | 1 + .../system/wordpress-freedombox.service | 37 ++++ .../systemd/system/wordpress-freedombox.timer | 11 + plinth/modules/wordpress/forms.py | 17 ++ plinth/modules/wordpress/manifest.py | 21 ++ plinth/modules/wordpress/tests/__init__.py | 0 .../wordpress/tests/test_functional.py | 167 ++++++++++++++++ plinth/modules/wordpress/urls.py | 13 ++ plinth/modules/wordpress/views.py | 38 ++++ static/themes/default/icons/wordpress.png | Bin 0 -> 18281 bytes static/themes/default/icons/wordpress.svg | 54 +++++ 15 files changed, 729 insertions(+) create mode 100755 actions/wordpress create mode 100644 plinth/modules/wordpress/__init__.py create mode 100644 plinth/modules/wordpress/data/etc/apache2/conf-available/wordpress-freedombox.conf create mode 100644 plinth/modules/wordpress/data/etc/plinth/modules-enabled/wordpress create mode 100644 plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.service create mode 100644 plinth/modules/wordpress/data/lib/systemd/system/wordpress-freedombox.timer create mode 100644 plinth/modules/wordpress/forms.py create mode 100644 plinth/modules/wordpress/manifest.py create mode 100644 plinth/modules/wordpress/tests/__init__.py create mode 100644 plinth/modules/wordpress/tests/test_functional.py create mode 100644 plinth/modules/wordpress/urls.py create mode 100644 plinth/modules/wordpress/views.py create mode 100644 static/themes/default/icons/wordpress.png create mode 100644 static/themes/default/icons/wordpress.svg 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 0000000000000000000000000000000000000000..7082d0f5018671d5803b98728542e093c20ee740 GIT binary patch literal 18281 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 +image/svg+xml