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 <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2021-07-28 18:37:04 -07:00 committed by James Valleroy
parent 5340cf3119
commit efa615201b
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
15 changed files with 729 additions and 0 deletions

189
actions/wordpress Executable file
View File

@ -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'''<?php
# Created by FreedomBox
include_once('{_db_file_path}');
define('DB_NAME', $dbname);
define('DB_USER', $dbuser);
define('DB_PASSWORD', $dbpass);
define('DB_HOST', $dbserver);
define('AUTH_KEY', '{secret_keys[0]}');
define('SECURE_AUTH_KEY', '{secret_keys[1]}');
define('LOGGED_IN_KEY', '{secret_keys[2]}');
define('NONCE_KEY', '{secret_keys[3]}');
define('AUTH_SALT', '{secret_keys[4]}');
define('SECURE_AUTH_SALT', '{secret_keys[5]}');
define('LOGGED_IN_SALT', '{secret_keys[6]}');
define('NONCE_SALT', '{secret_keys[7]}');
define('WP_CONTENT_DIR', '/var/lib/wordpress/wp-content');
define('DISABLE_WP_CRON', true);
'''
_config_file_path.write_text(config_contents)
db_contents = f'''<?php
# Created by FreedomBox
$dbuser='{db_user}';
$dbpass='{db_password}';
$dbname='{db_name}';
$dbserver='{db_host}';
'''
old_umask = os.umask(0o037)
try:
_db_file_path.write_text(db_contents)
finally:
os.umask(old_umask)
shutil.chown(_db_file_path, group='www-data')
def _create_database(db_name):
"""Create an empty MySQL database for WordPress."""
# Wordpress' install.php creates the tables.
# SQL injection is avoided due to known input.
query = f'''CREATE DATABASE {db_name};'''
subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
check=True)
def _set_privileges(db_host, db_name, db_user, db_password):
"""Create user, set password and provide permissions on the database."""
# SQL injection is avoided due to known input.
query = f'''GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,ALTER
ON {db_name}.*
TO {db_user}@{db_host}
IDENTIFIED BY '{db_password}';
FLUSH PRIVILEGES;
'''
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 WordPress."""
chars = chars or (string.ascii_letters + string.digits +
'!@#$%^&*()-_ []{}<>~`+=,.:/?|')
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()

6
debian/copyright vendored
View File

@ -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/)

View File

@ -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 <a '
'href="/wordpress/wp-admin/">admin page</a> 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)

View File

@ -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
<Directory /usr/share/wordpress>
Options FollowSymLinks
<IfModule mod_rewrite.c>
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]
</IfModule>
# Allow access only if site is marked as public or if user is an admin
<IfFile !/etc/wordpress/is_public>
Include includes/freedombox-single-sign-on.conf
<IfModule mod_auth_pubtkt.c>
TKTAuthToken "admin"
</IfModule>
</IfFile>
# Increase maximum upload file size
<IfModule proxy_fcgi_module>
ProxyFCGISetEnvIf true PHP_VALUE "post_max_size=128M \n upload_max_filesize = 128M"
</IfModule>
</Directory>
<Directory /var/lib/wordpress/wp-content>
Options FollowSymLinks
# Allow access only if site is marked as public or if user is an admin
<IfFile !/etc/wordpress/is_public>
Include includes/freedombox-single-sign-on.conf
<IfModule mod_auth_pubtkt.c>
TKTAuthToken "admin"
</IfModule>
</IfFile>
<IfFile /etc/wordpress/is_public>
Require all granted
</IfFile>
</Directory>

View File

@ -0,0 +1 @@
plinth.modules.wordpress

View File

@ -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

View File

@ -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

View File

@ -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.'))

View File

@ -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/']
},
}

View File

@ -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')

View File

@ -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'),
]

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="512"
height="512"
viewBox="0 0 512 512"
enable-background="new 0 0 64 64"
xml:space="preserve"
sodipodi:docname="wordpress.svg"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
inkscape:export-filename="/home/bunny/work/freedombox/freedombox/static/themes/default/icons/wordpress.png"
inkscape:export-xdpi="48"
inkscape:export-ydpi="48"><metadata
id="metadata15"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs13" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1848"
inkscape:window-height="1016"
id="namedview11"
showgrid="false"
inkscape:zoom="0.77929688"
inkscape:cx="458.42259"
inkscape:cy="386.63154"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"
inkscape:document-rotation="0" /><style
id="style2">.style0{fill: #0073aa;}</style><g
id="g8"
transform="scale(8)"><g
id="g6"><path
d="m 4.548,31.999 c 0,10.9 6.3,20.3 15.5,24.706 L 6.925,20.827 C 5.402,24.2 4.5,28 4.5,31.999 Z m 45.983,-1.385 c 0,-3.394 -1.219,-5.742 -2.264,-7.57 -1.391,-2.263 -2.695,-4.177 -2.695,-6.439 0,-2.523 1.912,-4.872 4.609,-4.872 0.121,0 0.2,0 0.4,0.022 C 45.653,7.3 39.1,4.5 32,4.548 c -9.591,0 -18.027,4.921 -22.936,12.4 0.645,0 1.3,0 1.8,0.033 2.871,0 7.316,-0.349 7.316,-0.349 1.479,-0.086 1.7,2.1 0.2,2.3 0,0 -1.487,0.174 -3.142,0.261 l 9.997,29.735 6.008,-18.017 -4.276,-11.718 c -1.479,-0.087 -2.879,-0.261 -2.879,-0.261 -1.48,-0.087 -1.306,-2.349 0.174,-2.262 0,0 4.5,0.3 7.2,0.349 2.87,0 7.317,-0.349 7.317,-0.349 1.479,-0.086 1.7,2.1 0.2,2.262 0,0 -1.489,0.174 -3.142,0.261 l 9.92,29.508 2.739,-9.148 C 49.628,35.7 50.5,33 50.5,30.614 Z M 32.481,34.4 24.244,58.334 c 2.46,0.7 5.1,1.1 7.8,1.1 3.197,0 6.262,-0.552 9.116,-1.556 -0.072,-0.118 -0.141,-0.243 -0.196,-0.379 z M 56.088,18.8 c 0.119,0.9 0.2,1.8 0.2,2.823 0,2.785 -0.521,5.916 -2.088,9.832 l -8.385,24.242 c 8.161,-4.758 13.65,-13.6 13.65,-23.728 C 59.451,27.2 58.2,22.7 56.1,18.83 Z M 32,0 C 14.355,0 0,14.355 0,32 0,49.6 14.4,64 32,64 49.6,64 64,49.645 64,31.999 64,14.4 49.6,0 32,0 Z m 0,62.533 C 15.165,62.533 1.467,48.835 1.467,31.999 1.467,15.2 15.2,1.5 32,1.5 48.8,1.5 62.534,15.2 62.5,32.032 62.533,48.8 48.8,62.5 32,62.533 Z"
class="style0"
id="path4" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB