bepasty: New app for file upload and sharing

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
James Valleroy 2020-08-05 19:42:13 -04:00 committed by Joseph Nuthalapati
parent 66537b1f9c
commit 7edc2f4e13
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35
17 changed files with 738 additions and 0 deletions

213
actions/bepasty Executable file
View File

@ -0,0 +1,213 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for bepasty.
"""
import argparse
import json
import grp
import os
import pwd
import secrets
import shutil
import string
import subprocess
from plinth import action_utils
from plinth.modules import bepasty
DATA_DIR = '/var/lib/bepasty'
CONF_FILE = '/etc/bepasty-freedombox.conf'
CONF_CONTENTS = """
SITENAME = '{}'
STORAGE_FILESYSTEM_DIRECTORY = '/var/lib/bepasty'
SECRET_KEY = '{}'
PERMISSIONS = {{
'{}': 'admin,list,create,read,delete', # admin
'{}': 'list,create,read,delete', # editor
'{}': 'list,read', # viewer
}}
"""
PASSWORD_LENGTH = 20
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
setup = subparsers.add_parser(
'setup', help='Perform post-installation operations for bepasty')
setup.add_argument('--domain-name',
help='The domain name that will be used by bepasty')
subparsers.add_parser(
'list-passwords',
help='Get a list of passwords, their permissions and comments')
add_password = subparsers.add_parser(
'add-password', help='Generate a password with given permissions')
add_password.add_argument(
'--permissions', nargs='*',
help='Any number of permissions from the set: {}'.format(', '.join(
bepasty.PERMISSIONS.keys())))
add_password.add_argument(
'--comment', required=False,
help='A comment for the password and its permissions')
remove_password = subparsers.add_parser(
'remove-password', help='Remove a password and its permissions')
remove_password.add_argument('--password', required=True,
help='The password to be removed')
subparsers.required = True
return parser.parse_args()
def subcommand_setup(arguments):
"""Post installation actions for bepasty."""
# Create bepasty group if needed.
try:
grp.getgrnam('bepasty')
except KeyError:
subprocess.run(['addgroup', '--system', 'bepasty'], check=True)
# Create bepasty user is needed.
try:
pwd.getpwnam('bepasty')
except KeyError:
subprocess.run([
'adduser', '--system', '--ingroup', 'bepasty', '--home',
'/var/lib/bepasty', '--gecos', 'bepasty file sharing', 'bepasty'
], check=True)
# Create data directory if needed.
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR, mode=0o750)
shutil.chown(DATA_DIR, user='bepasty', group='bepasty')
# Create configuration file if needed.
if not os.path.isfile(CONF_FILE):
# Generate secrets
secret_key = secrets.token_hex(64)
passwords = []
for i in range(3):
passwords.append(_generate_password())
with open(CONF_FILE, 'w') as conf_file:
conf_file.write(
CONF_CONTENTS.format(arguments.domain_name, secret_key,
*passwords))
os.chmod(CONF_FILE, 0o640)
shutil.chown(CONF_FILE, user='bepasty', group='bepasty')
def subcommand_list_passwords(_):
"""Get a list of passwords, their permissions and comments"""
with open(CONF_FILE, 'r') as conf_file:
lines = conf_file.readlines()
passwords = []
in_permissions = False
for line in lines:
if line.startswith('PERMISSIONS'):
in_permissions = True
elif in_permissions:
if line.startswith('}'):
in_permissions = False
else:
parts = line.split('#')
try:
comment = parts[1].strip()
except IndexError:
comment = ''
parts = parts[0].split(':')
password = parts[0].replace("'", '').strip()
permissions = parts[1].replace(
"'", '').strip().rstrip(',').split(',')
passwords.append({
'password': password,
'permissions': ', '.join(permissions),
'comment': comment
})
print(json.dumps(passwords))
def subcommand_add_password(arguments):
"""Generate a password with given permissions"""
if arguments.permissions:
permissions = set(bepasty.PERMISSIONS.keys()).intersection(
arguments.permissions)
permissions = ','.join(permissions)
else:
permissions = ''
password = _generate_password()
with open(CONF_FILE, 'r') as conf_file:
lines = conf_file.readlines()
with open(CONF_FILE, 'w') as conf_file:
in_permissions = False
for line in lines:
if line.startswith('PERMISSIONS'):
in_permissions = True
elif in_permissions:
if line.startswith('}'):
in_permissions = False
conf_file.write(" '{}': '{}',".format(
password, permissions))
if arguments.comment:
conf_file.write(' # {}'.format(arguments.comment))
conf_file.write('\n')
conf_file.write(line)
action_utils.service_try_restart('uwsgi')
def subcommand_remove_password(arguments):
"""Remove a password and its permissions"""
with open(CONF_FILE, 'r') as conf_file:
lines = conf_file.readlines()
with open(CONF_FILE, 'w') as conf_file:
in_permissions = False
for line in lines:
if line.startswith('PERMISSIONS'):
in_permissions = True
elif in_permissions:
if line.startswith('}'):
in_permissions = False
elif arguments.password in line:
continue
conf_file.write(line)
action_utils.service_try_restart('uwsgi')
def _generate_password():
"""Generate a random password"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for i in range(PASSWORD_LENGTH))
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()

26
debian/copyright vendored
View File

@ -39,6 +39,11 @@ Copyright: Marie Van den Broeck (https://thenounproject.com/marie49/)
Comment: https://thenounproject.com/icon/162372/
License: CC-BY-SA-3.0
Files: static/themes/default/icons/bepasty.svg
Copyright: (c) 2014 by the Bepasty Team, see the AUTHORS file.
Comment: https://github.com/bepasty/bepasty-server/blob/master/src/bepasty/static/app/bepasty.svg
License: BSD-2-clause
Files: static/themes/default/icons/cockpit.svg
Copyright: Cockpit Authors (https://github.com/cockpit-project/cockpit/blob/master/AUTHORS)
Comment: https://github.com/cockpit-project/cockpit/blob/master/src/branding/default/logo.svg
@ -979,6 +984,27 @@ License: Apache-2.0
On Debian systems, the full text of the Apache Software License version 2 can
be found in the file `/usr/share/common-licenses/Apache-2.0'.
License: BSD-2-clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
License: BSD-3-clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions

View File

@ -0,0 +1,115 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
FreedomBox app for bepasty.
"""
import json
from django.utils.translation import ugettext_lazy as _
from plinth import actions
from plinth import app as app_module
from plinth import frontpage, menu
from plinth.modules.apache.components import Uwsgi, Webserver
from plinth.modules.firewall.components import Firewall
from .manifest import backup, clients # noqa, pylint: disable=unused-import
version = 1
managed_packages = ['bepasty', 'uwsgi', 'uwsgi-plugin-python3']
managed_services = ['uwsgi']
description = [
_('bepasty is a web application that allows all types of files to be '
'uploaded and shared.'),
_('bepasty does not use usernames for login. It only uses passwords. For '
'each password, a set of permissions can be selected. Once you have '
'created a password, you can share it with the users who should have the'
' associated permissions.'),
_('You can also create multiple passwords with the same set of privileges,'
' and distribute them to different people or groups. This will allow '
'you to later revoke access for a single person or group, by removing '
'their password from the list.'),
]
app = None
PERMISSIONS = {
'read': _('Read files (using their web address)'),
'create': _('Create or upload files'),
'list': _('List all files'),
'delete': _('Delete files'),
'admin': _('Admin (lock/unlock files)'),
}
class BepastyApp(app_module.App):
"""FreedomBox app for bepasty."""
app_id = 'bepasty'
def __init__(self):
"""Create components for the app."""
super().__init__()
info = app_module.Info(self.app_id, version, name=_('bepasty'),
icon_filename='bepasty',
short_description=_('File Sharing'),
description=description, manual_page='bepasty',
clients=clients)
self.add(info)
menu_item = menu.Menu('menu-bepasty', info.name,
info.short_description, info.icon_filename,
'bepasty:index', parent_url_name='apps')
self.add(menu_item)
shortcut = frontpage.Shortcut('shortcut-bepasty', info.name,
info.short_description,
info.icon_filename, '/bepasty',
clients=clients)
self.add(shortcut)
firewall = Firewall('firewall-bepasty', info.name,
ports=['http', 'https'], is_external=True)
self.add(firewall)
uwsgi = Uwsgi('uwsgi-bepasty', 'bepasty-freedombox')
self.add(uwsgi)
webserver = Webserver('webserver-bepasty', 'bepasty-freedombox')
self.add(webserver)
def setup(helper, old_version=None):
"""Install and configure the module."""
helper.install(managed_packages)
helper.call('post', actions.superuser_run, 'bepasty',
['setup', '--domain-name', 'freedombox.local'])
helper.call('post', app.enable)
def list_passwords():
"""Get a list of passwords, their permissions and comments"""
output = actions.superuser_run('bepasty', ['list-passwords'])
return json.loads(output)
def add_password(permissions=None, comment=None):
"""Generate a password with given permissions"""
command = ['add-password']
if permissions:
command += ['--permissions'] + permissions
if comment:
command += ['--comment', comment]
actions.superuser_run('bepasty', command)
def remove_password(password):
"""Remove a password and its permissions"""
actions.superuser_run('bepasty',
['remove-password', '--password', password])

View File

@ -0,0 +1,16 @@
##
## On all sites, provide bepasty on a path: /bepasty
##
# Redirect /bepasty to /bepasty/
<Location ~ ^/bepasty$>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/bepasty$
RewriteRule .* /bepasty/ [R=301,L]
</IfModule>
</Location>
<Location /bepasty/>
ProxyPass unix:/run/uwsgi/app/bepasty-freedombox/socket|uwsgi://bepasty/
</Location>

View File

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

View File

@ -0,0 +1,31 @@
# Use packaged file after #966314 is done.
[uwsgi]
# Who will run the code
uid = bepasty
gid = bepasty
# disable logging for privacy
#disable-logging = true
autoload = false
# Number of workers (usually CPU count)
workers = 2
# The right granted on the created socket
chmod-socket = 666
# Plugin to use and interpretor config
single-interpreter = true
master = true
plugin = python3
enable-threads = true
lazy-apps = true
# Module to import
module = bepasty.wsgi
env = BEPASTY_CONFIG=/etc/bepasty-freedombox.conf
pythonpath = /usr/lib/python3/dist-packages/
buffer-size = 32768

View File

@ -0,0 +1,24 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Django forms for bepasty app.
"""
from django import forms
from django.utils.translation import ugettext_lazy as _
from plinth.modules import bepasty
class AddPasswordForm(forms.Form):
"""Form to add a new password."""
permissions = forms.MultipleChoiceField(
choices=bepasty.PERMISSIONS.items(),
widget=forms.CheckboxSelectMultiple, required=False,
label=_('Permissions'), help_text=_(
'Users that log in with this password will have the selected '
'permissions.'))
comment = forms.CharField(
label=_('Comment'), required=False, strip=True, help_text=_(
'Any comment to help you remember the purpose of this password.'))

View File

@ -0,0 +1,24 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from django.utils.translation import ugettext_lazy as _
from plinth.clients import validate
from plinth.modules.backups.api import validate as validate_backup
clients = validate([{
'name': _('bepasty'),
'platforms': [{
'type': 'web',
'url': '/bepasty'
}]
}])
backup = validate_backup({
'config': {
'files': ['/etc/bepasty-freedombox.conf']
},
'data': {
'directories': ['/var/lib/bepasty']
},
'services': ['uwsgi'],
})

View File

@ -0,0 +1,55 @@
{% extends "app.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block configuration %}
{{ block.super }}
<h3>{% trans "Manage Passwords" %}</h3>
<div class="btn-toolbar">
<a href="{% url 'bepasty:add' %}" class="btn btn-default"
role="button" title="{% trans 'Add password' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Add password' %}
</a>
</div>
{% if not passwords %}
<p>{% trans 'No passwords currently configured.' %}</p>
{% else %}
<table class="table table-bordered table-condensed table-striped" id="passwords-list">
<thead>
<tr>
<th>{% trans "Password" %}</th>
<th>{% trans "Permissions" %}</th>
<th>{% trans "Comment" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for password in passwords %}
<tr id="password-{{ password.password }}" class="password">
<td class="password-password">{{ password.password }}</td>
<td class="password-permissions">{{ password.permissions }}</td>
<td class="password-comment">{{ password.comment }}</td>
<td class="password-operations">
<form class="form form-inline" method="post"
action="{% url 'bepasty:remove' password.password %}">
{% csrf_token %}
<button class="password-remove btn btn-sm btn-default fa fa-trash-o"
type="submit"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% load static %}
{% block content %}
<h3>{{ title }}</h3>
<form class="form form-add" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Submit" %}"/>
</form>
{% endblock %}

View File

View File

@ -0,0 +1,39 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
@apps @bepasty
Feature: bepasty File Sharing
Run bepasty file upload and sharing app.
Background:
Given I'm a logged in user
Given the bepasty application is installed
Scenario: Enable bepasty application
Given the bepasty application is disabled
When I enable the bepasty application
Then the bepasty site should be available
Scenario: Add password
Given the bepasty application is enabled
When I add a password
Then I should be able to login to bepasty with that password
Scenario: Remove password
Given the bepasty application is enabled
When I remove all passwords
Then I should not be able to login to bepasty with that password
@backups
Scenario: Backup and restore bepasty
Given the bepasty application is enabled
When I add a password
And I create a backup of the bepasty app data with name test_bepasty
And I remove all passwords
And I restore the bepasty app data backup with name test_bepasty
Then the bepasty site should be available
And I should be able to login to bepasty with that password
Scenario: Disable bepasty application
Given the bepasty application is enabled
When I disable the bepasty application
Then the bepasty site should not be available

View File

@ -0,0 +1,72 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Functional, browser based tests for bepasty app.
"""
from pytest_bdd import scenarios, then, when
from plinth.tests import functional
scenarios('bepasty.feature')
last_password_added = None
@when('I add a password')
def add_password(session_browser):
global last_password_added
_remove_all_passwords(session_browser)
_add_password(session_browser)
last_password_added = _get_password(session_browser)
@when('I remove all passwords')
def remove_all_passwords(session_browser):
_remove_all_passwords(session_browser)
@then('I should be able to login to bepasty with that password')
def should_login(session_browser):
assert _can_login(session_browser, last_password_added)
@then('I should not be able to login to bepasty with that password')
def should_not_login(session_browser):
assert not _can_login(session_browser, last_password_added)
def _add_password(browser):
functional.visit(browser, '/plinth/apps/bepasty/add')
for permission in ['read', 'create', 'list', 'delete', 'admin']:
browser.find_by_css('#id_bepasty-permissions input[value="{}"]'.format(
permission)).check()
browser.fill('bepasty-comment', 'bepasty functional test')
functional.submit(browser, form_class='form-add')
def _remove_all_passwords(browser):
functional.visit(browser, '/plinth/apps/bepasty')
while True:
remove_button = browser.find_by_css('.password-remove')
if remove_button:
functional.submit(browser, remove_button)
else:
break
def _get_password(browser):
functional.visit(browser, '/plinth/apps/bepasty')
return browser.find_by_css('.password-password').first.text
def _can_login(browser, password):
functional.visit(browser, '/bepasty')
logout = browser.find_by_value('Logout')
if logout:
logout.click()
browser.fill('token', password)
login = browser.find_by_xpath('//form//button')
functional.submit(browser, login, '/bepasty')
return bool(browser.find_by_value('Logout'))

View File

@ -0,0 +1,15 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
URLs for the bepasty module.
"""
from django.conf.urls import url
from .views import AddPasswordView, BepastyView, remove
urlpatterns = [
url(r'^apps/bepasty/$', BepastyView.as_view(), name='index'),
url(r'^apps/bepasty/add/$', AddPasswordView.as_view(), name='add'),
url(r'^apps/bepasty/(?P<password>[A-Za-z0-9]+)/remove/$', remove,
name='remove'),
]

View File

@ -0,0 +1,62 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Views for the bepasty app.
"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.http import require_POST
from django.views.generic import FormView
from plinth.modules import bepasty
from plinth.views import AppView
from .forms import AddPasswordForm
class BepastyView(AppView):
"""Serve configuration page."""
app_id = 'bepasty'
diagnostics_module_name = 'bepasty'
template_name = 'bepasty.html'
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['passwords'] = bepasty.list_passwords()
return context
class AddPasswordView(SuccessMessageMixin, FormView):
"""View to add a new password."""
form_class = AddPasswordForm
prefix = 'bepasty'
template_name = 'bepasty_add.html'
success_url = reverse_lazy('bepasty:index')
success_message = _('Password added.')
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Add Password')
return context
def form_valid(self, form):
"""Add the password on valid form submission."""
_add_password(form.cleaned_data)
return super().form_valid(form)
def _add_password(form_data):
bepasty.add_password(form_data['permissions'], form_data['comment'])
@require_POST
def remove(request, password):
"""View to remove a password."""
bepasty.remove_password(password)
messages.success(request, _('Password deleted.'))
return redirect(reverse_lazy('bepasty:index'))

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:osb="http://www.openswatchbook.org/uri/2009/osb" 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" width="1792" height="1792" viewBox="0 0 1792 1792" id="svg3384" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="bepasty.svg" inkscape:export-filename="/media/Volume/Dokumente/Inkscape/bepasty.png" inkscape:export-xdpi="90" inkscape:export-ydpi="90">
<metadata id="metadata3392">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs3390">
<linearGradient id="linearGradient4986" osb:paint="solid">
<stop style="stop-color:#ffffff;stop-opacity:1;" offset="0" id="stop4988"/>
</linearGradient>
</defs>
<sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1920" inkscape:window-height="1011" id="namedview3388" showgrid="false" inkscape:zoom="0.22739956" inkscape:cx="410.40107" inkscape:cy="781.49232" inkscape:window-x="0" inkscape:window-y="27" inkscape:window-maximized="1" inkscape:current-layer="svg3384"/>
<path d="M1596 380q28 28 48 76t20 88v1152q0 40-28 68t-68 28h-1344q-40 0-68-28t-28-68v-1600q0-40 28-68t68-28h896q40 0 88 20t76 48zm-444-244v376h376q-10-29-22-41l-313-313q-12-12-41-22zm384 1528v-1024h-416q-40 0-68-28t-28-68v-416h-768v1536h1280z" id="path3386" style="fill:#0000ff"/>
<text xml:space="preserve" style="font-style:normal;font-weight:normal;font-size:37.96647644px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#0000ff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" x="70.780403" y="1636.4702" id="text3394" sodipodi:linespacing="125%" transform="scale(0.95420813,1.0479894)"><tspan sodipodi:role="line" id="tspan3396" x="70.780403" y="1636.4702" style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:2254.98071289px;font-family:'Cabin Condensed';-inkscape-font-specification:'Cabin Condensed, ';fill:#0000ff">B</tspan></text>
<text xml:space="preserve" style="font-style:normal;font-weight:normal;font-size:40px;line-height:87.99999952%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#0000ff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" x="1412.0957" y="762.97424" id="text3398" sodipodi:linespacing="88%"><tspan sodipodi:role="line" id="tspan3400" x="1412.0957" y="762.97424" style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:237.5px;line-height:87.99999952%;font-family:'Cabin Condensed';-inkscape-font-specification:'Cabin Condensed, ';fill:#0000ff">p</tspan><tspan sodipodi:role="line" x="1412.0957" y="971.97424" id="tspan3402" style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:237.5px;line-height:87.99999952%;font-family:'Cabin Condensed';-inkscape-font-specification:'Cabin Condensed, ';fill:#0000ff">a</tspan><tspan sodipodi:role="line" x="1412.0957" y="1180.9742" id="tspan3404" style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:237.5px;line-height:87.99999952%;font-family:'Cabin Condensed';-inkscape-font-specification:'Cabin Condensed, ';fill:#0000ff">s</tspan><tspan sodipodi:role="line" x="1412.0957" y="1389.9742" id="tspan3406" style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:237.5px;line-height:87.99999952%;font-family:'Cabin Condensed';-inkscape-font-specification:'Cabin Condensed, ';fill:#0000ff">t</tspan><tspan sodipodi:role="line" x="1412.0957" y="1598.9742" id="tspan3408" style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:237.5px;line-height:87.99999952%;font-family:'Cabin Condensed';-inkscape-font-specification:'Cabin Condensed, ';fill:#0000ff">y</tspan></text>
<g inkscape:groupmode="layer" id="layer1" inkscape:label="E" style="display:inline;opacity:0.578"/>
<g inkscape:groupmode="layer" id="layer2" inkscape:label="Deck" style="display:inline;opacity:1"/>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB