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 @@ + +image/svg+xml