FreedomBox/plinth/web_server.py
Sunil Mohan Adapa 168f662a17
*: Update URL base from /plinth to /freedombox
- Since we are going to be an OpenID Provider, we need to fix the URLs that
other apps will be configured with for authentication. So change now from
/plinth to /freedombox. If done later, it will be harder since all the
configuration files for all dependent apps will need to be updated.

Tests:

- App availability checking works. Request goes to /freedombox URL

- Favicon is served properly and through /favicon.ico URL

- Redirection happens from / to /freedombox directly

- UI is available on /freedombox and on /plinth

- Manual page show /freedombox as the URL in two places

- Static files are successfully served from /freedombox URLs. URLs inside page
start with /freedombox

- backup, bepasty, calibre, config, dynamicdns, ejabberd, featherwiki, gitweb,
ikiwiki, kiwix, miniflux, names, openvpn, shadowsocks, shadowsocksserver,
sharing, shapshot, tiddlywiki, users, wireguard, jsxc, matrixsynapse, first
wizard, storage, samba, tags functional tests work. Backup/restore test for
matrixsynapse fails due to an unrelated bug (server not restarted after
restore).

- Setting the home page works:

  - Having /plinth in the home page configuration works. Shows selection
    correctly.

  - Setting to app works. Shows selection correctly.

  - Setting to user home page (sets /freedombox). Shows selection correctly.

  - Setting to apache default works. Shows selection correctly.

  - Changing back to FreedomBox service works. Shows selection correctly.

- Unit tests work

- Configuration page shows /freedombox in description but not /plinth

- Diagnostics show /freedombox in tests

- Roundcube URL link in email app has /freedombox

- email loads the page /.well-known/autoconfig/mail/config-v1.1.xml correctly

- email app shows /freedombox/apps/roundcube for /roundcube if roundcube is not
installed.

- networks: router configuration page shows URL starting with /freedombox.

- snapshot: Shows URL starting with /freedombox on the app page

- js licenses page uses /freedombox prefix for JSXC.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2026-03-02 20:50:30 -05:00

188 lines
6.4 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Setup CherryPy web server.
"""
import logging
import os
import pathlib
import sys
import warnings
from typing import ClassVar
import cherrypy
from . import app as app_module
from . import cfg, log, web_framework
logger = logging.getLogger(__name__)
# When an app installs a python module as a dependency and imports it. CherryPy
# will start monitoring it for changes. When the app is uninstalled, the module
# is removed from the system leading to change detected by CherryPy. The entire
# service is then restarted if it is in development mode. This could cause a
# temporary failure in requests served leading to failures in functional tests.
# Workaround this by preventing auto-reloading for all but FreedomBox's python
# modules.
AUTORELOAD_REGEX = r'^plinth'
_CUSTOM_STATIC_URL = '/custom/static'
_USER_CSS_PATH = 'css/user.css'
def get_custom_static_url():
"""Return the URL path fragment for custom static URL."""
return f'{cfg.server_dir}{_CUSTOM_STATIC_URL}'
def get_user_css():
"""Return the URL path fragement for user CSS if it exists else None."""
user_css_path = pathlib.Path(cfg.custom_static_dir) / _USER_CSS_PATH
if not user_css_path.exists():
return None
return get_custom_static_url() + '/' + _USER_CSS_PATH
def _mount_static_directory(static_dir, static_url):
config = {
'/': {
'tools.staticdir.root': static_dir,
'tools.staticdir.on': True,
'tools.staticdir.dir': '.'
}
}
app = cherrypy.tree.mount(None, static_url, config)
log.setup_cherrypy_static_directory(app)
def init():
"""Setup CherryPy server"""
logger.info('Setting up CherryPy server')
# Configure default server
cherrypy.config.update({
'server.max_request_body_size': 0,
'server.socket_host': cfg.host,
'server.socket_port': cfg.port,
'server.thread_pool': 10,
# Avoid stating files once per second in production
'engine.autoreload.on': cfg.develop,
'engine.autoreload.match': AUTORELOAD_REGEX,
})
def _logging_middleware(application):
"""A WSGI middleware to log messages before executing them."""
def _wrapper(environ, start_response):
"""Log request, then hand control to original app."""
logger.info("%s %s", environ['REQUEST_METHOD'],
environ['PATH_INFO'])
return application(environ, start_response)
return _wrapper
application = web_framework.get_wsgi_application()
cherrypy.tree.graft(_logging_middleware(application), cfg.server_dir)
static_dir = os.path.join(cfg.file_root, 'static')
_mount_static_directory(static_dir, web_framework.get_static_url())
custom_static_dir = cfg.custom_static_dir
custom_static_url = get_custom_static_url()
if os.path.exists(custom_static_dir):
_mount_static_directory(custom_static_dir, custom_static_url)
else:
logger.debug(
'Not serving custom static directory %s on %s, '
'directory does not exist', custom_static_dir, custom_static_url)
_mount_static_directory('/usr/share/javascript', '/javascript')
for app in app_module.App.list():
module = sys.modules[app.__module__]
module_path = os.path.dirname(module.__file__)
static_dir = os.path.join(module_path, 'static')
if not os.path.isdir(static_dir):
continue
urlprefix = "%s%s" % (web_framework.get_static_url(), app.app_id)
_mount_static_directory(static_dir, urlprefix)
for component in StaticFiles.list():
component.mount()
cherrypy.engine.signal_handler.subscribe()
def run(on_web_server_stop):
"""Start the web server and block it until exit."""
with warnings.catch_warnings():
# Suppress warning that some of the static directories don't exist.
# Since there is no way to add/remove those tree mounts at will, we
# need to add them all before hand even if they don't exist now. If the
# directories becomes available later, CherryPy serves them just fine.
warnings.filterwarnings(
'ignore', '(.|\n)*is not an existing filesystem path(.|\n)*',
UserWarning)
cherrypy.engine.start()
cherrypy.engine.subscribe('stop', on_web_server_stop)
def block():
"""Block the calling thread until web server exits."""
cherrypy.engine.block()
class StaticFiles(app_module.FollowerComponent):
"""Component to serve static files shipped with an app.
Any files in <app>/static directory will be automatically served on
/static/<app>/ directory by FreedomBox. This allows each app to ship custom
static files that are served by the web server.
However, in some rare circumstances, a system folder will need to be served
on a path for the app to work. This component allows declaring such
directories and the web paths they should be served on.
"""
_all_instances: ClassVar[dict[str, 'StaticFiles']] = {}
def __init__(self, component_id, directory_map=None):
"""Initialize the component.
component_id should be a unique ID across all components of an app and
across all components.
directory_map should be a dictionary with keys to be web paths and
values to be absolute path of the directory on disk to serve. The
static files from the directory are served over the given web path. The
web path will be prepended with the FreedomBox's configured base web
path. For example, {'/foo': '/usr/share/foo'} means that
'/usr/share/foo/bar.png' will be served over '/freedombox/foo/bar.png'
if FreedomBox is configured to be served on '/freedombox'.
"""
super().__init__(component_id)
self.directory_map = directory_map
self._all_instances[component_id] = self
@classmethod
def list(cls):
"""Return a list of all instances."""
return cls._all_instances.values()
def mount(self):
"""Perform configuration of the web server to handle static files.
Called by web server abstraction layer after web server has been setup.
"""
if self.directory_map:
for web_path, file_path in self.directory_map.items():
web_path = '%s%s' % (cfg.server_dir, web_path)
_mount_static_directory(file_path, web_path)