diff --git a/.gitignore b/.gitignore
index ba9c727d6..3b9739d04 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
current-*.tar.gz
*.pyc
*.py.bak
+*.swp
*.tiny.css
data/*.log
data/cherrypy_sessions
@@ -28,4 +29,4 @@ data/plinth.sqlite3
predepend
build/
*.pid
-.emacs.desktop*
\ No newline at end of file
+.emacs.desktop*
diff --git a/actions.py b/actions.py
index 38ab78d25..083dd520c 100644
--- a/actions.py
+++ b/actions.py
@@ -32,7 +32,8 @@ Actions run commands with this contract (version 1.1):
C. Only one action can be called at a time.
- This prevents us from appending multiple (unexpected) actions to the call.
+ This prevents us from appending multiple (unexpected) actions to the
+ call.
$ action="echo '$options'; echo 'oops'"
$ options="hi"
@@ -51,8 +52,8 @@ Actions run commands with this contract (version 1.1):
easier than detecting if it occurs.
The options list is coerced into a space-separated string before being
- shell-escaped. Option lists including shell escape characters may need to
- be unescaped on the receiving end.
+ shell-escaped. Option lists including shell escape characters may need
+ to be unescaped on the receiving end.
E. Actions must exist in the actions directory.
@@ -72,11 +73,19 @@ Actions run commands with this contract (version 1.1):
"""
+import logging
import os
-import pipes, shlex, subprocess
+import pipes
+import subprocess
+
+import cfg
+from errors import ActionError
-def run(action, options = None, async = False):
+LOGGER = logging.getLogger(__name__)
+
+
+def run(action, options=None, async=False):
"""Safely run a specific action as the current user.
See actions._run for more information.
@@ -84,7 +93,8 @@ def run(action, options = None, async = False):
"""
return _run(action, options, async, False)
-def superuser_run(action, options = None, async = False):
+
+def superuser_run(action, options=None, async=False):
"""Safely run a specific action as root.
See actions._run for more information.
@@ -92,27 +102,26 @@ def superuser_run(action, options = None, async = False):
"""
return _run(action, options, async, True)
-def _run(action, options = None, async = False, run_as_root = False):
+
+def _run(action, options=None, async=False, run_as_root=False):
"""Safely run a specific action as a normal user or root.
- actions are pulled from the actions directory.
+ Actions are pulled from the actions directory.
+ - options are added to the action command.
+ - async: run asynchronously or wait for the command to complete.
+ - run_as_root: execute the command through sudo.
- options are added to the action command.
-
- async: run asynchronously or wait for the command to complete.
-
- run_as_root: execute the command through sudo.
"""
- DIRECTORY = "actions"
-
- if options == None:
+ if options is None:
options = []
# contract 3A and 3B: don't call anything outside of the actions directory.
if os.sep in action:
raise ValueError("Action can't contain:" + os.sep)
- cmd = DIRECTORY + os.sep + action
+ cmd = os.path.join(cfg.actions_dir, action)
+ if not os.path.realpath(cmd).startswith(cfg.actions_dir):
+ raise ValueError("Action has to be in directory %s" % cfg.actions_dir)
# contract 3C: interpret shell escape sequences as literal file names.
# contract 3E: fail if the action doesn't exist or exists elsewhere.
@@ -121,25 +130,32 @@ def _run(action, options = None, async = False, run_as_root = False):
cmd = [cmd]
- # contract: 3C, 3D: don't allow users to insert escape characters in options
+ # contract: 3C, 3D: don't allow users to insert escape characters in
+ # options
if options:
if not hasattr(options, "__iter__"):
options = [options]
-
cmd += [pipes.quote(option) for option in options]
# contract 1: commands can run via sudo.
if run_as_root:
cmd = ["sudo", "-n"] + cmd
+ LOGGER.info('Executing command - %s', cmd)
+
# contract 3C: don't interpret shell escape sequences.
# contract 5 (and 6-ish).
proc = subprocess.Popen(
cmd,
- stdout = subprocess.PIPE,
- stderr= subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
shell=False)
if not async:
output, error = proc.communicate()
- return output, error
+ if proc.returncode != 0:
+ LOGGER.error('Error executing command - %s, %s, %s', cmd, output,
+ error)
+ raise ActionError(action, output, error)
+
+ return output
diff --git a/cfg.py b/cfg.py
index c322b404e..4844393c4 100644
--- a/cfg.py
+++ b/cfg.py
@@ -12,6 +12,7 @@ python_root = None
data_dir = None
store_file = None
user_db = None
+actions_dir = None
status_log_file = None
access_log_file = None
pidfile = None
@@ -19,7 +20,7 @@ host = None
port = None
debug = False
no_daemon = False
-server_dir = ''
+server_dir = '/'
main_menu = Menu()
@@ -41,6 +42,7 @@ def read():
('Path', 'data_dir'),
('Path', 'store_file'),
('Path', 'user_db'),
+ ('Path', 'actions_dir'),
('Path', 'status_log_file'),
('Path', 'access_log_file'),
('Path', 'pidfile'),
diff --git a/context_processors.py b/context_processors.py
new file mode 100644
index 000000000..087158953
--- /dev/null
+++ b/context_processors.py
@@ -0,0 +1,34 @@
+#
+# This file is part of Plinth.
+#
+# 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
Welcome screen not completely implemented yet. Press continue to see the rest of the + href="{% url 'apps:index' %}">continue to see the rest of the web interface.
We live in a world where our use of the network is mediated by diff --git a/modules/help/templates/help.html b/modules/help/templates/help.html index 6fd191d75..6269ecfa7 100644 --- a/modules/help/templates/help.html +++ b/modules/help/templates/help.html @@ -23,7 +23,7 @@
There are a variety of places to go for help with {{ cfg.product_name }} and the box it runs on.
-This front end has a
+ This front end has a
developer's manual. It isn't complete, but it is the first place
to look. Feel free to offer suggestions, edits, and screenshots for
completing it!
There is no FAQ because +
There is no FAQ because the question frequency is currently zero for all questions.
{% endblock %} diff --git a/modules/help/urls.py b/modules/help/urls.py index 3c623ebcc..38adfc807 100644 --- a/modules/help/urls.py +++ b/modules/help/urls.py @@ -24,11 +24,11 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.help.help', - url(r'^help/$', 'index'), - url(r'^help/index/$', 'index'), - url(r'^help/about/$', 'about'), - url(r'^help/view/(?PWhen enabled, the owncloud installation will be available - from owncloud on the web server. Visit + from owncloud on the web server. Visit this URL to set up the initial administration account for owncloud.
diff --git a/modules/owncloud/urls.py b/modules/owncloud/urls.py index 5ad666505..faa23d43b 100644 --- a/modules/owncloud/urls.py +++ b/modules/owncloud/urls.py @@ -24,5 +24,5 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.owncloud.owncloud', - url(r'^apps/owncloud/$', 'index'), + url(r'^apps/owncloud/$', 'index', name='index'), ) diff --git a/modules/packages/packages.py b/modules/packages/packages.py index cbae9198a..ee4c52717 100644 --- a/modules/packages/packages.py +++ b/modules/packages/packages.py @@ -10,19 +10,13 @@ import cfg def get_modules_available(): """Return list of all modules""" - output, error = actions.run('module-manager', ['list-available']) - if error: - raise Exception('Error getting modules: %s' % error) - + output = actions.run('module-manager', ['list-available']) return output.split() def get_modules_enabled(): """Return list of all modules""" - output, error = actions.run('module-manager', ['list-enabled']) - if error: - raise Exception('Error getting enabled modules - %s' % error) - + output = actions.run('module-manager', ['list-enabled']) return output.split() @@ -42,8 +36,8 @@ class PackagesForm(forms.Form): def init(): """Initialize the Packages module""" - menu = cfg.main_menu.find('/sys') - menu.add_item('Package Manager', 'icon-gift', '/sys/packages', 20) + menu = cfg.main_menu.get('system:index') + menu.add_urlname('Package Manager', 'icon-gift', 'packages:index', 20) @login_required @@ -88,13 +82,12 @@ def _apply_changes(request, old_status, new_status): module = field.split('_enabled')[0] if enabled: - output, error = actions.superuser_run( - 'module-manager', ['enable', cfg.python_root, module]) - del output # Unused - - # TODO: need to get plinth to load the module we just - # enabled - if error: + try: + actions.superuser_run( + 'module-manager', ['enable', cfg.python_root, module]) + except Exception: + # TODO: need to get plinth to load the module we just + # enabled messages.error( request, _('Error enabling module - {module}').format( module=module)) @@ -103,13 +96,12 @@ def _apply_changes(request, old_status, new_status): request, _('Module enabled - {module}').format( module=module)) else: - output, error = actions.superuser_run( - 'module-manager', ['disable', cfg.python_root, module]) - del output # Unused - - # TODO: need a smoother way for plinth to unload the - # module - if error: + try: + actions.superuser_run( + 'module-manager', ['disable', cfg.python_root, module]) + except Exception: + # TODO: need a smoother way for plinth to unload the + # module messages.error( request, _('Error disabling module - {module}').format( module=module)) diff --git a/modules/packages/urls.py b/modules/packages/urls.py index ae963fb4e..60faf35bf 100644 --- a/modules/packages/urls.py +++ b/modules/packages/urls.py @@ -24,5 +24,5 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.packages.packages', - url(r'^sys/packages/$', 'index'), + url(r'^sys/packages/$', 'index', name='index'), ) diff --git a/modules/pagekite/pagekite.py b/modules/pagekite/pagekite.py index 9856f9da1..f6e90ced0 100644 --- a/modules/pagekite/pagekite.py +++ b/modules/pagekite/pagekite.py @@ -23,6 +23,7 @@ from django import forms from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core import validators +from django.core.urlresolvers import reverse_lazy from django.template import RequestContext from django.template.loader import render_to_string from django.template.response import TemplateResponse @@ -38,16 +39,16 @@ LOGGER = logging.getLogger(__name__) def init(): """Intialize the PageKite module""" - menu = cfg.main_menu.find('/apps') - menu.add_item(_('Public Visibility (PageKite)'), 'icon-flag', - '/apps/pagekite', 50) + menu = cfg.main_menu.get('apps:index') + menu.add_urlname(_('Public Visibility (PageKite)'), 'icon-flag', + 'pagekite:index', 50) @login_required def index(request): """Serve introdution page""" menu = {'title': _('PageKite'), - 'items': [{'url': '/apps/pagekite/configure', + 'items': [{'url': reverse_lazy('pagekite:configure'), 'text': _('Configure PageKite')}]} sidebar_right = render_to_string('menu_block.html', {'menu': menu}, @@ -197,15 +198,7 @@ def _run(arguments, superuser=True): """Run an given command and raise exception if there was an error""" command = 'pagekite-configure' - LOGGER.info('Running command - %s, %s, %s', command, arguments, superuser) - if superuser: - output, error = actions.superuser_run(command, arguments) + return actions.superuser_run(command, arguments) else: - output, error = actions.run(command, arguments) - - if error: - raise Exception('Error setting/getting PageKite confguration - %s' - % error) - - return output + return actions.run(command, arguments) diff --git a/modules/pagekite/templates/pagekite_introduction.html b/modules/pagekite/templates/pagekite_introduction.html index 21668bdea..d1b675aea 100644 --- a/modules/pagekite/templates/pagekite_introduction.html +++ b/modules/pagekite/templates/pagekite_introduction.html @@ -49,7 +49,7 @@ there. In future, it might be possible to use your buddy'sConfigure + href="{% url 'pagekite:configure' %}">Configure PageKite
diff --git a/modules/pagekite/urls.py b/modules/pagekite/urls.py index 8cbad4dd5..3db1d2f98 100644 --- a/modules/pagekite/urls.py +++ b/modules/pagekite/urls.py @@ -24,6 +24,6 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.pagekite.pagekite', - url(r'^apps/pagekite/$', 'index'), - url(r'^apps/pagekite/configure/$', 'configure'), + url(r'^apps/pagekite/$', 'index', name='index'), + url(r'^apps/pagekite/configure/$', 'configure', name='configure'), ) diff --git a/modules/system/system.py b/modules/system/system.py index e94c85b85..961461983 100644 --- a/modules/system/system.py +++ b/modules/system/system.py @@ -6,7 +6,7 @@ import cfg def init(): """Initialize the system module""" - cfg.main_menu.add_item(_('System'), 'icon-cog', '/sys', 100) + cfg.main_menu.add_urlname(_('System'), 'icon-cog', 'system:index', 100) def index(request): diff --git a/modules/system/urls.py b/modules/system/urls.py index 054f9afcc..5982ce850 100644 --- a/modules/system/urls.py +++ b/modules/system/urls.py @@ -24,5 +24,5 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.system.system', - url(r'^sys/$', 'index'), + url(r'^sys/$', 'index', name='index'), ) diff --git a/modules/tor/tor.py b/modules/tor/tor.py index ee3cdfdb9..335250983 100644 --- a/modules/tor/tor.py +++ b/modules/tor/tor.py @@ -29,15 +29,14 @@ import cfg def init(): """Initialize the Tor module""" - menu = cfg.main_menu.find('/apps') - menu.add_item("Tor", "icon-eye-close", "/apps/tor", 30) + menu = cfg.main_menu.get('apps:index') + menu.add_urlname("Tor", "icon-eye-close", "tor:index", 30) @login_required def index(request): """Service the index page""" - output, error = actions.superuser_run("tor-get-ports") - del error # Unused + output = actions.superuser_run("tor-get-ports") port_info = output.split("\n") tor_ports = {} diff --git a/modules/tor/urls.py b/modules/tor/urls.py index 95e0422fc..4c13cb138 100644 --- a/modules/tor/urls.py +++ b/modules/tor/urls.py @@ -24,5 +24,5 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.tor.tor', - url(r'^apps/tor/$', 'index') + url(r'^apps/tor/$', 'index', name='index') ) diff --git a/modules/users/urls.py b/modules/users/urls.py index 8fb5d21cd..0c46aa72e 100644 --- a/modules/users/urls.py +++ b/modules/users/urls.py @@ -24,7 +24,7 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.users.users', - url(r'^sys/users/$', 'index'), - url(r'^sys/users/add/$', 'add'), - url(r'^sys/users/edit/$', 'edit') + url(r'^sys/users/$', 'index', name='index'), + url(r'^sys/users/add/$', 'add', name='add'), + url(r'^sys/users/edit/$', 'edit', name='edit') ) diff --git a/modules/users/users.py b/modules/users/users.py index 4ce4bc316..5197b28ba 100644 --- a/modules/users/users.py +++ b/modules/users/users.py @@ -3,6 +3,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core import validators +from django.core.urlresolvers import reverse_lazy from django.template import RequestContext from django.template.loader import render_to_string from django.template.response import TemplateResponse @@ -18,17 +19,17 @@ LOGGER = logging.getLogger(__name__) def init(): """Intialize the module""" - menu = cfg.main_menu.find('/sys') - menu.add_item(_('Users and Groups'), 'icon-user', '/sys/users', 15) + menu = cfg.main_menu.get('system:index') + menu.add_urlname(_('Users and Groups'), 'icon-user', 'users:index', 15) @login_required def index(request): """Return a rendered users page""" menu = {'title': _('Users and Groups'), - 'items': [{'url': '/sys/users/add', + 'items': [{'url': reverse_lazy('users:add'), 'text': _('Add User')}, - {'url': '/sys/users/edit', + {'url': reverse_lazy('users:edit'), 'text': _('Edit Users')}]} sidebar_right = render_to_string('menu_block.html', {'menu': menu}, diff --git a/modules/xmpp/urls.py b/modules/xmpp/urls.py index 43a518c87..050026d81 100644 --- a/modules/xmpp/urls.py +++ b/modules/xmpp/urls.py @@ -24,7 +24,7 @@ from django.conf.urls import patterns, url urlpatterns = patterns( # pylint: disable-msg=C0103 'modules.xmpp.xmpp', - url(r'^apps/xmpp/$', 'index'), - url(r'^apps/xmpp/configure/$', 'configure'), - url(r'^apps/xmpp/register/$', 'register') + url(r'^apps/xmpp/$', 'index', name='index'), + url(r'^apps/xmpp/configure/$', 'configure', name='configure'), + url(r'^apps/xmpp/register/$', 'register', name='register') ) diff --git a/modules/xmpp/xmpp.py b/modules/xmpp/xmpp.py index 592dd3b01..fb09eef94 100644 --- a/modules/xmpp/xmpp.py +++ b/modules/xmpp/xmpp.py @@ -1,6 +1,7 @@ from django import forms from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse_lazy from django.template import RequestContext from django.template.loader import render_to_string from django.template.response import TemplateResponse @@ -14,18 +15,26 @@ import service LOGGER = logging.getLogger(__name__) -SIDE_MENU = {'title': _('XMPP'), - 'items': [{'url': '/apps/xmpp/configure', - 'text': 'Configure XMPP Server'}, - {'url': '/apps/xmpp/register', - 'text': 'Register XMPP Account'}]} +SIDE_MENU = { + 'title': _('XMPP'), + 'items': [ + { + 'url': reverse_lazy('xmpp:configure'), + 'text': _('Configure XMPP Server'), + }, + { + 'url': reverse_lazy('xmpp:register'), + 'text': _('Register XMPP Account'), + } + ] +} def init(): """Initialize the XMPP module""" - menu = cfg.main_menu.find('/apps') + menu = cfg.main_menu.get('apps:index') menu.add_item('Chat', 'icon-comment', '/../jwchat', 20) - menu.add_item('XMPP', 'icon-comment', '/apps/xmpp', 40) + menu.add_urlname('XMPP', 'icon-comment', 'xmpp:index', 40) service.Service( 'xmpp-client', _('Chat Server - client connections'), @@ -88,10 +97,7 @@ def configure(request): def get_status(): """Return the current status""" - output, error = actions.run('xmpp-setup', 'status') - if error: - raise Exception('Error getting status: %s' % error) - + output = actions.run('xmpp-setup', 'status') return {'inband_enabled': 'inband_enable' in output.split()} @@ -111,11 +117,7 @@ def _apply_changes(request, old_status, new_status): option = 'noinband_enable' LOGGER.info('Option - %s', option) - - _output, error = actions.superuser_run('xmpp-setup', [option]) - del _output # Unused - if error: - raise Exception('Error running command - %s' % error) + actions.superuser_run('xmpp-setup', [option]) class RegisterForm(forms.Form): # pylint: disable-msg=W0232 @@ -151,10 +153,8 @@ def register(request): def _register_user(request, data): """Register a new XMPP user""" - output, error = actions.superuser_run( + output = actions.superuser_run( 'xmpp-register', [data['username'], data['password']]) - if error: - raise Exception('Error registering user - %s' % error) if 'successfully registered' in output: messages.success(request, _('Registered account for %s') % diff --git a/plinth.py b/plinth.py index 674835196..969e0b9b4 100755 --- a/plinth.py +++ b/plinth.py @@ -35,6 +35,7 @@ def parse_arguments(): parser.add_argument( '--pidfile', default='plinth.pid', help='specify a file in which the server may write its pid') + # TODO: server_dir is actually a url prefix; use a better variable name parser.add_argument( '--server_dir', default='/', help='web server path under which to serve') @@ -101,7 +102,22 @@ def setup_server(): '/': {'tools.staticdir.root': '%s/static' % cfg.file_root, 'tools.staticdir.on': True, 'tools.staticdir.dir': '.'}} - cherrypy.tree.mount(None, cfg.server_dir + '/static', config) + cherrypy.tree.mount(None, django.conf.settings.STATIC_URL, config) + + # TODO: our modules are mimicking django apps. It'd be better to convert + # our modules to Django apps instead of reinventing the wheel. + # (we'll still have to serve the static files with cherrypy though) + for module in module_loader.LOADED_MODULES: + static_dir = os.path.join(cfg.file_root, 'modules', module, 'static') + if not os.path.isdir(static_dir): + continue + + config = { + '/': {'tools.staticdir.root': static_dir, + 'tools.staticdir.on': True, + 'tools.staticdir.dir': '.'}} + urlprefix = "%s%s" % (django.conf.settings.STATIC_URL, module) + cherrypy.tree.mount(None, urlprefix, config) if not cfg.no_daemon: Daemonizer(cherrypy.engine).subscribe() @@ -109,23 +125,14 @@ def setup_server(): cherrypy.engine.signal_handler.subscribe() -def context_processor(request): - """Add additional context values to RequestContext for use in templates""" - path_parts = request.path.split('/') - active_menu_urls = ['/'.join(path_parts[:length]) - for length in xrange(1, len(path_parts))] - return { - 'cfg': cfg, - 'main_menu': cfg.main_menu, - 'submenu': cfg.main_menu.active_item(request), - 'request_path': request.path, - 'basehref': cfg.server_dir, - 'active_menu_urls': active_menu_urls - } - - def configure_django(): """Setup Django configuration in the absense of .settings file""" + + # In module_loader.py we reverse URLs for the menu before having a proper + # request. In this case, get_script_prefix (used by reverse) returns the + # wrong prefix. Set it here manually to have the correct prefix right away. + django.core.urlresolvers.set_script_prefix(cfg.server_dir) + context_processors = [ 'django.contrib.auth.context_processors.auth', 'django.core.context_processors.debug', @@ -134,7 +141,7 @@ def configure_django(): 'django.core.context_processors.static', 'django.core.context_processors.tz', 'django.contrib.messages.context_processors.messages', - 'plinth.context_processor'] + 'context_processors.common'] logging_configuration = { 'version': 1, @@ -179,9 +186,18 @@ def configure_django(): 'django.contrib.contenttypes', 'django.contrib.messages'], LOGGING=logging_configuration, - LOGIN_URL=cfg.server_dir + '/accounts/login/', - LOGIN_REDIRECT_URL=cfg.server_dir + '/', - LOGOUT_URL=cfg.server_dir + '/accounts/logout/', + LOGIN_URL='lib:login', + LOGIN_REDIRECT_URL='apps:index', + LOGOUT_URL='lib:logout', + MIDDLEWARE_CLASSES=( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'modules.first_boot.middleware.FirstBootMiddleware', + ), ROOT_URLCONF='urls', SESSION_ENGINE='django.contrib.sessions.backends.file', SESSION_FILE_PATH=sessions_directory, diff --git a/plinth.sample.config b/plinth.sample.config index bbd023a23..4a900b7c9 100644 --- a/plinth.sample.config +++ b/plinth.sample.config @@ -10,6 +10,7 @@ log_dir = %(data_dir)s pid_dir = %(data_dir)s python_root = %(file_root)s server_dir = plinth/ +actions_dir = %(file_root)s/actions # file locations store_file = %(data_dir)s/store.sqlite3 diff --git a/templates/base.html b/templates/base.html index 9dfab0814..c5c142875 100644 --- a/templates/base.html +++ b/templates/base.html @@ -47,6 +47,8 @@ {{ css|safe }} + {% block app_head %}{% endblock %} + {% block page_head %}{% endblock %} @@ -59,15 +61,15 @@ - +
- FreedomBox
+ FreedomBox
{% block add_nav_and_login %}