janus: Add new app for lightweight WebRTC server

- Add basic video room based on demo.

- Set port range to use for RTP.

- coturn: Add component for time-limited TURN configuration.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
[sunil: Don't error out when coturn is not installed/configured]
[sunil: Prepend data- to custom attribute in HTML]
[sunil: Convert SVG with embedded bitmap to vector graphics]
[sunil: Hide Javascript license information in footer]
[sunil: Minor changes to comments for styling]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
James Valleroy 2022-03-08 16:20:43 -05:00 committed by Sunil Mohan Adapa
parent 4e5835f92a
commit b7a1d4bf8f
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
20 changed files with 1856 additions and 2 deletions

46
actions/janus Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for Janus server.
"""
import argparse
from plinth import action_utils
JANUS_CONF_PATH = '/etc/janus/janus.jcfg'
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='Configure Janus server')
subparsers.required = True
return parser.parse_args()
def subcommand_setup(_):
"""Configure Janus server."""
with open(JANUS_CONF_PATH, 'r') as config_file:
config_lines = config_file.readlines()
with open(JANUS_CONF_PATH, 'w') as config_file:
for line in config_lines:
if '#rtp_port_range' in line:
config_file.write("\trtp_port_range = \"50176-51199\"\n")
else:
config_file.write(line)
action_utils.service_try_restart('janus')
def main():
arguments = parse_arguments()
sub_command = arguments.subcommand.replace('-', '_')
sub_command_method = globals()['subcommand_' + sub_command]
sub_command_method(arguments)
if __name__ == '__main__':
main()

29
debian/copyright vendored
View File

@ -123,6 +123,11 @@ Files: static/themes/default/icons/infinoted.png
Copyright: 2008-2014 Armin Burgmeier <armin@arbur.net>
License: ISC
Files: static/themes/default/icons/janus.png
static/themes/default/icons/janus.svg
Copyright: 2014-2022 Meetecho
License: GPL-3 with OpenSSL exception
Files: static/themes/default/icons/macos.png
static/themes/default/icons/macos.svg
Copyright: Vectors Market (https://thenounproject.com/vectorsmarket/)
@ -2655,6 +2660,30 @@ License: GPL-3
On Debian systems you will find a copy of the GPL (version 3) at
/usr/share/common-licenses/GPL-3.
License: GPL-3 with OpenSSL exception
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3.
.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
.
On Debian systems you will find a copy of the GPL (version 3) at
/usr/share/common-licenses/GPL-3.
.
If you modify this Program, or any covered work, by linking or
combining it with OpenSSL (or a modified version of that library),
containing parts covered by the terms of OpenSSL License, the
licensors of this Program grant you additional permission to convey
the resulting work. Corresponding Source for a non-source form of
such a combination shall include the source code for the parts of
openssl used as well as that of the covered work.
License: GPL-3+
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

View File

@ -4,12 +4,20 @@
from __future__ import annotations # Can be removed in Python 3.10
import base64
import hashlib
import hmac
import json
import re
from dataclasses import dataclass, field
from time import time
from plinth import app
TURN_REST_TTL = 24 * 3600
TURN_REST_USER = 'fbxturnuser'
TURN_URI_REGEX = r'(stun|turn):(.*):([0-9]{4})\?transport=(tcp|udp)'
@ -55,6 +63,32 @@ class TurnConfiguration:
return all(map(pattern.match, turn_uris))
@dataclass
class UserTurnConfiguration(TurnConfiguration):
"""Data class to hold per-user TURN server configuration.
username is a string to identify a specific user and is related to
the credential.
credential is a string generated for this user that must be used
by a server to be accepted by Coturn server. It is generated for
each user separately upon request and will expire after a set
time.
"""
username: str = None
credential: str = None
def to_json(self) -> str:
"""Return a JSON representation of the configuration."""
return json.dumps({
'domain': self.domain,
'uris': self.uris,
'username': self.username,
'credential': self.credential
})
class TurnConsumer(app.FollowerComponent):
"""Component to manage coturn configuration.
@ -96,3 +130,29 @@ class TurnConsumer(app.FollowerComponent):
"""Return current coturn configuration."""
from plinth.modules import coturn
return coturn.get_config()
class TurnTimeLimitedConsumer(TurnConsumer):
"""Component to manage coturn configuration with time-limited
credential.
This component will generate a new credential upon each request,
which will expire after 1 day. The shared secret is used to
generate the credential, but is not provided in the configuration.
"""
def get_configuration(self) -> UserTurnConfiguration:
"""Return user coturn configuration."""
from plinth.modules import coturn
static_config = coturn.get_config()
timestamp = int(time()) + TURN_REST_TTL
username = str(timestamp) + ':' + TURN_REST_USER
credential = None
if static_config.shared_secret:
digest = hmac.new(bytes(static_config.shared_secret, 'utf-8'),
bytes(username, 'utf-8'), hashlib.sha1).digest()
credential = base64.b64encode(digest).decode()
return UserTurnConfiguration(static_config.domain, static_config.uris,
None, username, credential)

View File

@ -10,7 +10,8 @@ import pytest
from plinth.utils import random_string
from .. import notify_configuration_change
from ..components import TurnConfiguration, TurnConsumer
from ..components import (TurnConfiguration, TurnConsumer,
TurnTimeLimitedConsumer, UserTurnConfiguration)
@pytest.fixture(name='turn_configuration')
@ -48,13 +49,23 @@ def test_configuration_init():
assert config.domain == 'test-domain.example'
assert config.uris == ['test-uri1', 'test-uri2']
config = UserTurnConfiguration('test-domain.example',
['test-uri1', 'test-uri2'], None,
'test-username', 'test-credential')
assert config.domain == 'test-domain.example'
assert config.uris == ['test-uri1', 'test-uri2']
assert config.shared_secret is None
assert config.username == 'test-username'
assert config.credential == 'test-credential'
def test_component_init_and_list():
"""Test initializing and listing all the components."""
component1 = TurnConsumer('component1')
component2 = TurnConsumer('component2')
component3 = TurnTimeLimitedConsumer('component3')
assert component1.component_id == 'component1'
assert [component1, component2] == list(TurnConsumer.list())
assert [component1, component2, component3] == list(TurnConsumer.list())
@patch('plinth.modules.coturn.get_config')
@ -73,3 +84,16 @@ def test_get_configuration(get_config, turn_configuration):
get_config.return_value = turn_configuration
component = TurnConsumer('component')
assert component.get_configuration() == turn_configuration
@patch('plinth.modules.coturn.get_config')
def test_get_user_configuration(get_config, turn_configuration):
"""Test coturn user configuration retrieval using component."""
get_config.return_value = turn_configuration
component = TurnTimeLimitedConsumer('component')
user_config = component.get_configuration()
assert user_config.domain == turn_configuration.domain
assert user_config.uris == turn_configuration.uris
assert user_config.shared_secret is None
assert user_config.username is not None
assert user_config.credential is not None

View File

@ -0,0 +1,94 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
FreedomBox app for janus.
"""
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from plinth import actions
from plinth import app as app_module
from plinth import frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.backups.components import BackupRestore
from plinth.modules.coturn.components import TurnTimeLimitedConsumer
from plinth.modules.firewall.components import Firewall
from plinth.package import Packages
from . import manifest
_description = [
_('Janus is a lightweight WebRTC server.'),
_('A simple video conference room is included.'),
_('A STUN/TURN server (such as Coturn) is required to use Janus.'),
]
app = None
class JanusApp(app_module.App):
"""FreedomBox app for janus."""
app_id = 'janus'
_version = 1
def __init__(self):
"""Create components for the app."""
super().__init__()
info = app_module.Info(self.app_id, self._version, name=_('Janus'),
icon_filename='janus',
short_description=_('WebRTC server'),
description=_description, manual_page='Janus',
clients=manifest.clients)
self.add(info)
menu_item = menu.Menu('menu-janus', info.name, info.short_description,
info.icon_filename, 'janus:index',
parent_url_name='apps')
self.add(menu_item)
shortcut = frontpage.Shortcut('shortcut-janus', info.name,
info.short_description,
info.icon_filename,
reverse_lazy('janus:room'),
clients=manifest.clients)
self.add(shortcut)
packages = Packages('packages-janus', [
'janus', 'libjs-bootbox', 'libjs-bootstrap', 'libjs-bootswatch',
'libjs-janus-gateway', 'libjs-jquery-blockui', 'libjs-spin.js',
'libjs-toastr', 'libjs-webrtc-adapter'
])
self.add(packages)
firewall = Firewall('firewall-janus', info.name,
ports=['http', 'https',
'janus-freedombox'], is_external=True)
self.add(firewall)
webserver = Webserver('webserver-janus', 'janus-freedombox')
self.add(webserver)
daemon = Daemon(
'daemon-janus', 'janus', listen_ports=[(8088, 'tcp4'),
(8088, 'tcp6'),
(8188, 'tcp4'),
(8188, 'tcp6')])
self.add(daemon)
turn = TurnTimeLimitedConsumer('turn-janus')
self.add(turn)
backup_restore = BackupRestore('backup-restore-janus',
**manifest.backup)
self.add(backup_restore)
def setup(helper, old_version=None):
"""Install and configure the app."""
app.setup(old_version)
actions.superuser_run('janus', ['setup'])
helper.call('post', app.enable)

View File

@ -0,0 +1,13 @@
##
## On all sites, provide Janus web and websockets APIs.
##
<Location /janus>
ProxyPass http://127.0.0.1:8088/janus retry=0
ProxyPassReverse http://127.0.0.1:8088/janus
</Location>
<Location /janus-ws>
ProxyPass ws://127.0.0.1:8188 retry=0
ProxyPassReverse ws://127.0.0.1:8188
</Location>

View File

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

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>Janus WebRTC server/gateway</short>
<description>Janus is a minimal, general purpose WebRTC server/gateway. It provides the means to set up a WebRTC media communication with a browser or application, exchanging JSON messages with it over different transports, and relaying RTP/RTCP and messages between clients and the server-side application logic they're attached to. Any specific feature/application is provided by server side plugins.</description>
<port protocol="udp" port="50176-51199"/>
</service>

View File

@ -0,0 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
clients = [{
'name': _('Janus Video Room'),
'platforms': [{
'type': 'web',
'url': reverse_lazy('janus:room')
}]
}]
backup = {}

View File

@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
#
# This file is part of FreedomBox.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
*/
var server = "/janus";
var iceServers = null;
var token = "";
var apisecret = "";
$(document).ready(function() {
const body = document.querySelector('body');
const config = JSON.parse(body.getAttribute('data-user-turn-config'));
iceServers = [{urls: config['uris'],
username: config['username'],
credential: config['credential']}];
});

View File

@ -0,0 +1,170 @@
/*
# SPDX-License-Identifier: AGPL-3.0-or-later
# This file based on example code from Janus Demos which is licensed as
# follows.
#
# 2014-2022 Meetecho
#
# GPL-3 with OpenSSL exception
# If you modify this Program, or any covered work,
# by linking or combining it with OpenSSL
# (or a modified version of that library),
# containing parts covered by the terms of OpenSSL License,
# the licensors of this Program grant you
# additional permission to convey the resulting work.
# Corresponding Source for a non-source form of such a combination
# shall include the source code for the parts of openssl used
# as well as that of the covered work.
*/
.footer {
display: none;
}
.rounded {
border-radius: 5px;
}
.centered {
display: block;
margin: auto;
}
.relative {
position: relative;
}
.navbar-brand {
margin-left: 0px !important;
}
.navbar-default {
-webkit-box-shadow: 0px 3px 5px rgba(100, 100, 100, 0.49);
-moz-box-shadow: 0px 3px 5px rgba(100, 100, 100, 0.49);
box-shadow: 0px 3px 5px rgba(100, 100, 100, 0.49);
}
.navbar-header {
padding-left: 40px;
}
.margin-sm {
margin: 5px !important;
}
.margin-md {
margin: 10px !important;
}
.margin-xl {
margin: 20px !important;
}
.margin-bottom-sm {
margin-bottom: 5px !important;
}
.margin-bottom-md {
margin-bottom: 10px !important;
}
.margin-bottom-xl {
margin-bottom: 20px !important;
}
.divider {
width: 100%;
text-align: center;
}
.divider hr {
margin-left: auto;
margin-right: auto;
width: 45%;
}
div.no-video-container {
position: relative;
}
.no-video-icon {
width: 100%;
height: 240px;
text-align: center;
}
.no-video-text {
text-align: center;
position: absolute;
bottom: 0px;
right: 0px;
left: 0px;
font-size: 24px;
}
.no-video-text-sm {
text-align: center;
position: absolute;
bottom: 0px;
right: 0px;
left: 0px;
font-size: 16px;
}
.meetecho-logo {
padding: 12px !important;
}
.meetecho-logo > img {
height: 26px;
}
pre {
white-space: pre-wrap;
white-space: -moz-pre-wrap;
white-space: -pre-wrap;
white-space: -o-pre-wrap;
word-wrap: break-word;
}
.januscon {
font-weight: bold;
animation: pulsating 1s infinite;
}
@keyframes pulsating {
30% {
color: #FFD700;
}
}
.mute-button {
position: absolute;
bottom: 0px;
left: 0px;
margin: 15px;
}
.unpublish-button {
position: absolute;
bottom: 0px;
right: 0px;
margin: 15px;
}
.resolution-label {
position: absolute;
bottom: 0px;
left: 0px;
margin: 15px;
}
.bitrate-label {
position: absolute;
bottom: 0px;
right: 0px;
margin: 15px;
}
.simulcast-button-group {
width: 100%;
}
.simulcast-button {
width: 33%;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,209 @@
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
# This file based on example code from Janus which is
# licensed as follows.
#
# 2014-2022 Meetecho
#
# GPL-3 with OpenSSL exception
# If you modify this Program, or any covered work,
# by linking or combining it with OpenSSL
# (or a modified version of that library),
# containing parts covered by the terms of OpenSSL License,
# the licensors of this Program grant you
# additional permission to convey the resulting work.
# Corresponding Source for a non-source form of such a combination
# shall include the source code for the parts of openssl used
# as well as that of the covered work.
{% endcomment %}
{% load static %}
{% load i18n %}
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Janus Video Room</title>
<script type="text/javascript"
src="/javascript/webrtc-adapter/adapter.min.js" ></script>
<script type="text/javascript"
src="/javascript/jquery/jquery.min.js" ></script>
<script type="text/javascript"
src="/javascript/jquery-blockui/jquery.blockUI.js" ></script>
<script type="text/javascript"
src="/javascript/bootstrap/js/bootstrap.min.js" ></script>
<script type="text/javascript"
src="/javascript/bootbox/bootbox.min.js" ></script>
<script type="text/javascript"
src="/javascript/spin.js/spin.min.js" ></script>
<script type="text/javascript"
src="/javascript/toastr/toastr.min.js" ></script>
<script type="text/javascript"
src="{% static 'janus/janus-freedombox-config.js' %}" ></script>
<script type="text/javascript"
src="/javascript/janus-gateway/janus.min.js" ></script>
<script type="text/javascript"
src="{% static 'janus/janus-video-room.js' %}" ></script>
<link rel="stylesheet"
href="/javascript/bootswatch/cerulean/bootstrap.min.css"
type="text/css"/>
<link rel="stylesheet" href="/javascript/toastr/toastr.min.css"
type="text/css"/>
<link rel="stylesheet"
href="{% static 'janus/janus-video-room.css' %}" type="text/css"/>
</head>
<body data-user-turn-config="{{ user_turn_config }}">
<div class="container" id="content" role="main">
<div class="row">
<div class="col-md-12">
<h1>Janus Video Room
<button class="btn btn-default" autocomplete="off"
id="start">Start</button>
</h1>
</div>
<div class="container" id="details">
<div class="row">
<div class="col-md-12">
<h3>Details</h3>
<p>To use the video room, just insert a username to join
the default room that is configured. This will add you
to the list of participants, and allow you to
automatically send your audio/video frames and receive
the other participants' feeds. The other participants
will appear in separate panels, whose title will be
the names they chose when registering at the video room.</p>
<p>Press the <code>Start</code> button above to launch
the video room.</p>
</div>
</div>
</div>
<div class="container hide" id="videojoin">
<div class="row">
<span class="label label-info" id="you"></span>
<div class="col-md-12" id="controls">
<div class="input-group margin-bottom-md hide" id="registernow">
<span class="input-group-addon">@</span>
<input autocomplete="off" class="form-control"
autocomplete="off" type="text"
placeholder="Choose a display name"
id="username"
onkeypress="return checkEnter(this, event);"></input>
<span class="input-group-btn">
<button class="btn btn-success" autocomplete="off"
id="register">Join the room</button>
</span>
</div>
</div>
</div>
</div>
<div class="container hide" id="videos">
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Local Video <span class="label label-primary hide"
id="publisher"></span>
<div class="btn-group btn-group-xs pull-right hide">
<div class="btn-group btn-group-xs">
<button id="bitrateset" autocomplete="off"
class="btn btn-primary
dropdown-toggle"
data-toggle="dropdown">
Bandwidth<span class="caret"></span>
</button>
<ul id="bitrate" class="dropdown-menu" role="menu">
<li><a href="#" id="0">No limit</a></li>
<li><a href="#" id="128">Cap to 128kbit</a></li>
<li><a href="#" id="256">Cap to 256kbit</a></li>
<li><a href="#" id="512">Cap to 512kbit</a></li>
<li><a href="#" id="1024">Cap to 1mbit</a></li>
<li><a href="#" id="1500">Cap to 1.5mbit</a></li>
<li><a href="#" id="2000">Cap to 2mbit</a></li>
</ul>
</div>
</div>
</h3>
</div>
<div class="panel-body" id="videolocal"></div>
</div>
</div>
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Remote Video #1 <span class="label label-info hide"
id="remote1"></span>
</h3>
</div>
<div class="panel-body relative" id="videoremote1"></div>
</div>
</div>
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Remote Video #2 <span class="label label-info hide"
id="remote2"></span>
</h3>
</div>
<div class="panel-body relative" id="videoremote2"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Remote Video #3 <span class="label label-info hide"
id="remote3"></span>
</h3>
</div>
<div class="panel-body relative" id="videoremote3"></div>
</div>
</div>
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Remote Video #4 <span class="label label-info hide"
id="remote4"></span>
</h3>
</div>
<div class="panel-body relative" id="videoremote4"></div>
</div>
</div>
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Remote Video #5 <span class="label label-info hide"
id="remote5"></span>
</h3>
</div>
<div class="panel-body relative" id="videoremote5"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<a href="{% static 'jslicense.html' %}" data-jslicense="1">
{% trans "JavaScript license information" %}</a>
</div>
</body>
</html>

View File

View File

@ -0,0 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Functional, browser based tests for Janus app.
"""
import pytest
from plinth.tests import functional
pytestmark = [pytest.mark.apps, pytest.mark.janus]
class TestJanusApp(functional.BaseAppTests):
app_name = 'janus'
has_service = True
has_web = True

View File

@ -0,0 +1,17 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
URLs for the janus app.
"""
from django.urls import re_path
from stronghold.decorators import public
from plinth.views import AppView
from .views import JanusRoomView
urlpatterns = [
re_path(r'^apps/janus/$', AppView.as_view(app_id='janus'), name='index'),
re_path(r'^apps/janus/room/$', public(JanusRoomView.as_view()),
name='room')
]

View File

@ -0,0 +1,20 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Views for the Janus app.
"""
from django.views.generic import TemplateView
from plinth.modules import janus
class JanusRoomView(TemplateView):
"""A simple page to host Janus video room."""
template_name = 'janus_video_room.html'
def get_context_data(self, *args, **kwargs):
"""Add user's TURN server information to view context."""
config = janus.app.get_component('turn-janus').get_configuration()
context = super().get_context_data(*args, **kwargs)
context['user_turn_config'] = config.to_json()
return context

View File

@ -36,6 +36,7 @@ markers = [
"i2p",
"ikiwiki",
"infinoted",
"janus",
"jsxc",
"matrixsynapse",
"mediawiki",

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="svg60"
width="512"
height="512"
viewBox="0 0 512.00001 512.00001"
sodipodi:docname="janus.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata
id="metadata66">
<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="defs64" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1846"
inkscape:window-height="1016"
id="namedview62"
showgrid="false"
inkscape:zoom="1.2118871"
inkscape:cx="217.01691"
inkscape:cy="305.72154"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g68"
inkscape:pagecheckerboard="0" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g68">
<path
id="rect1364"
style="display:inline;fill:#0071bc;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:7;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 167.07289,203.07798 v 26.49811 l -57.58467,39.36696 v 84.41487 l 57.58467,-36.89065 v 113.79689 h 81.6724 V 203.07798 Z"
sodipodi:nodetypes="ccccccccc" />
<path
id="path13100"
style="display:inline;fill:#00ccff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:7;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 344.2499,202.90079 v 26.49811 l 56.34207,-26.32092 -4e-5,81.6377 -56.34203,31.57441 V 430.08698 H 262.57749 V 202.90079 Z"
sodipodi:nodetypes="ccccccccc" />
<ellipse
style="display:inline;fill:#00ccff;fill-opacity:1;stroke:#ffffff;stroke-width:7;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path16994"
cx="304.2576"
cy="137.60036"
rx="55.329285"
ry="55.610538" />
<ellipse
style="display:inline;fill:#0071bc;fill-opacity:1;stroke:#ffffff;stroke-width:7;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="ellipse17076"
cx="208.16225"
cy="136.78899"
rx="55.329285"
ry="55.610538" />
<ellipse
style="display:inline;fill:none;fill-opacity:1;stroke:#0071bc;stroke-width:27.7001;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path19871"
cy="256.00003"
cx="256"
rx="242.14993"
ry="242.14996" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB