diff --git a/plinth/modules/apache/__init__.py b/plinth/modules/apache/__init__.py index f599030e0..0551b8ad2 100644 --- a/plinth/modules/apache/__init__.py +++ b/plinth/modules/apache/__init__.py @@ -2,6 +2,7 @@ """ FreedomBox app for Apache server. """ +import os from django.utils.translation import ugettext_lazy as _ @@ -11,7 +12,7 @@ from plinth import cfg from plinth.daemon import Daemon from plinth.modules.firewall.components import Firewall from plinth.modules.letsencrypt.components import LetsEncrypt -from plinth.utils import format_lazy +from plinth.utils import format_lazy, is_valid_user_name version = 8 @@ -19,8 +20,9 @@ is_essential = True managed_services = ['apache2', 'uwsgi'] -managed_packages = ['apache2', 'php-fpm', 'ssl-cert', 'uwsgi', - 'uwsgi-plugin-python3'] +managed_packages = [ + 'apache2', 'php-fpm', 'ssl-cert', 'uwsgi', 'uwsgi-plugin-python3' +] app = None @@ -63,3 +65,81 @@ def setup(helper, old_version=None): actions.superuser_run( 'apache', ['setup', '--old-version', str(old_version)]) + + +# (U)ser (W)eb (S)ites + + +def uws_usr2dir(user): + """Returns the directory of the given user's website.""" + return '/home/{}/public_html'.format(user) + + +def uws_usr2url(user): + """Returns the url path of the given user's website.""" + return '/~{}/'.format(user) + + +def uws_dir2usr(directory): + """Returns the user of a given user website directory.""" + if directory.startswith('/home/'): + pos_ini = 6 + elif directory.startswith('home/'): + pos_ini = 5 + else: + return None + + pos_end = directory.find('/public_html') + if pos_end == -1: + return None + + user = directory[pos_ini:pos_end] + return user if is_valid_user_name(user) else None + + +def uws_url2usr(url): + """Returns the user of a given user website url path.""" + MISSING = -1 + + pos_ini = url.find('~') + if pos_ini == MISSING: + return None + + pos_end = url.find('/', pos_ini) + if pos_end == MISSING: + pos_end = len(url) + + user = url[pos_ini + 1:pos_end] + return user if is_valid_user_name(user) else None + + +def uws_url2dir(url): + """Returns the directory of the user's website for the given url path. + + Note: It doesn't return the full OS file path to the url path! + """ + return uws_usr2dir(uws_url2usr(url)) + + +def uws_dir2url(directory): + """Returns the url base path of the user's website for the given OS path. + + Note: It doesn't return the url path for the file! + """ + return uws_usr2url(uws_dir2usr(directory)) + + +def get_users_with_website(): + """Returns a dictionary of users with actual website subdirectory.""" + + def lst_sub_dirs(directory): + """Returns the list of subdirectories of the given directory.""" + return [ + name for name in os.listdir(directory) + if os.path.isdir(os.path.join(directory, name)) + ] + + return { + name: uws_usr2url(name) + for name in lst_sub_dirs('/home') if os.path.isdir(uws_usr2dir(name)) + } diff --git a/plinth/modules/apache/tests/test_components.py b/plinth/modules/apache/tests/test_components.py index bdfaaf516..61b71e3b5 100644 --- a/plinth/modules/apache/tests/test_components.py +++ b/plinth/modules/apache/tests/test_components.py @@ -72,6 +72,7 @@ def test_webserver_disable(superuser_run): @patch('plinth.modules.apache.components.diagnose_url_on_all') def test_webserver_diagnose(diagnose_url_on_all, diagnose_url): """Test running diagnostics.""" + def on_all_side_effect(url, check_certificate): return [('test-result-' + url, 'success')] diff --git a/plinth/modules/apache/tests/test_uws.py b/plinth/modules/apache/tests/test_uws.py new file mode 100644 index 000000000..d28f581f2 --- /dev/null +++ b/plinth/modules/apache/tests/test_uws.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test module for (U)ser (Web) (S)ites. +""" + +from plinth.modules.apache import (uws_usr2dir, uws_dir2url, uws_url2usr, + uws_usr2url, uws_url2dir, uws_dir2usr) + + +def test_uws_namings(): + """Test name solvers for user, url and directory of UWS.""" + + assert '/home/usr/public_html' == uws_usr2dir('usr') + assert '/~usr/' == uws_usr2url('usr') + + f = uws_dir2usr + assert f('/home/usr/lacks/the/UWS/directory') is None + assert 'usr' == f('/home/usr/public_html/is/a/normal/UWS/file') + assert 'usr' == f('/home/usr/public_html/is/a/normal/UWS/path/') + assert '€.;#@|' == f('/home/€.;#@|/public_html/is/stange/but/valid/') + + f = uws_url2usr + assert f('/usr/is/not/a/valid/UWS/url/due/to/missing/tilde') is None + assert 'usr' == f('whatever/~usr/is/considered/a/valid/UWS/path') + assert 'usr' == f('~usr') + assert 'usr' == f('~usr/') + assert 'usr' == f('/~usr/') + + assert '/home/usr/public_html' == uws_url2dir('~usr/any/file') + assert '/~usr/' == uws_dir2url('/home/usr/public_html/path/to/file') diff --git a/plinth/modules/config/__init__.py b/plinth/modules/config/__init__.py index a0c60f932..72828bec0 100644 --- a/plinth/modules/config/__init__.py +++ b/plinth/modules/config/__init__.py @@ -12,6 +12,8 @@ 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 import (uws_url2usr, uws_usr2url, + get_users_with_website) from plinth.modules.names.components import DomainType from plinth.signals import domain_added @@ -81,13 +83,56 @@ def get_hostname(): return socket.gethostname() -def _get_home_page_url(): +def home_page_url2scid(url): + """Returns the shortcut ID of the given home page url.""" + + if url in ('/plinth/', '/plinth', 'plinth'): + return 'plinth' + + if url == '/index.html': + return 'apache-default' + + if url and url.startswith('/~'): + return 'uws-{}'.format(uws_url2usr(url)) + + shortcuts = frontpage.Shortcut.list() + for shortcut in shortcuts: + if shortcut.url == url: + return shortcut.component_id + + return None + + +def _home_page_scid2url(shortcut_id): + """Returns the url for the given home page shortcut ID.""" + if shortcut_id is None: + url = None + elif shortcut_id == 'plinth': + url = '/plinth/' + elif shortcut_id == 'apache-default': + url = '/index.html' + elif shortcut_id.startswith('uws-'): + user = shortcut_id[4:] + if user in get_users_with_website(): + url = uws_usr2url(user) + else: + url = None + else: + shortcuts = frontpage.Shortcut.list() + aux = [ + shortcut.url for shortcut in shortcuts + if shortcut_id == shortcut.component_id + ] + url = aux[0] if 1 == len(aux) else None + + return url + + +def _get_home_page_url(conf_file): """Get the default application for the domain.""" aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + augeas.Augeas.NO_MODL_AUTOLOAD) aug.set('/augeas/load/Httpd/lens', 'Httpd.lns') - conf_file = APACHE_HOMEPAGE_CONFIG if os.path.exists( - APACHE_HOMEPAGE_CONFIG) else FREEDOMBOX_APACHE_CONFIG aug.set('/augeas/load/Httpd/incl[last() + 1]', conf_file) aug.load() @@ -103,35 +148,20 @@ def _get_home_page_url(): def get_home_page(): """Return the shortcut ID that is set as current home page.""" - url = _get_home_page_url() - if url in ['/plinth/', '/plinth', 'plinth']: - return 'plinth' + CONF_FILE = APACHE_HOMEPAGE_CONFIG if os.path.exists( + APACHE_HOMEPAGE_CONFIG) else FREEDOMBOX_APACHE_CONFIG - if url == '/index.html': - return 'apache-default' - - shortcuts = frontpage.Shortcut.list() - for shortcut in shortcuts: - if shortcut.url == url: - return shortcut.component_id - - return None + url = _get_home_page_url(CONF_FILE) + return home_page_url2scid(url) def change_home_page(shortcut_id): """Change the FreedomBox's default redirect to URL of the shortcut specified. """ - if shortcut_id == 'plinth': - url = '/plinth/' - elif shortcut_id == 'apache-default': - url = '/index.html' - else: - shortcuts = frontpage.Shortcut.list() - url = [ - shortcut.url for shortcut in shortcuts - if shortcut.component_id == shortcut_id - ][0] + url = _home_page_scid2url(shortcut_id) + if url is None: + url = '/plinth/' # fall back to default url if scid is unknown. # URL may be a reverse_lazy() proxy actions.superuser_run('config', ['set-home-page', str(url)]) diff --git a/plinth/modules/config/forms.py b/plinth/modules/config/forms.py index 2194a0a7f..df71dcdb1 100644 --- a/plinth/modules/config/forms.py +++ b/plinth/modules/config/forms.py @@ -14,6 +14,9 @@ from django.utils.translation import ugettext_lazy from plinth import cfg, frontpage from plinth.utils import format_lazy +from plinth.modules.apache import get_users_with_website + +from . import home_page_url2scid logger = logging.getLogger(__name__) @@ -32,9 +35,13 @@ def get_homepage_choices(): shortcuts = frontpage.Shortcut.list(web_apps_only=True) shortcut_choices = [(shortcut.component_id, shortcut.name) for shortcut in shortcuts if shortcut.is_enabled()] + uws_choices = \ + [(home_page_url2scid(url), + format_lazy(ugettext_lazy("{user}'s website"), user=user)) + for user, url in get_users_with_website().items()] apache_default = ('apache-default', _('Apache Default')) plinth = ('plinth', _('FreedomBox Service (Plinth)')) - return [apache_default, plinth] + shortcut_choices + return [apache_default, plinth] + uws_choices + shortcut_choices class ConfigurationForm(forms.Form): diff --git a/plinth/modules/config/tests/test_config.py b/plinth/modules/config/tests/test_config.py index 277901d08..3296caf3f 100644 --- a/plinth/modules/config/tests/test_config.py +++ b/plinth/modules/config/tests/test_config.py @@ -3,11 +3,16 @@ Tests for config module. """ +import pytest import os -from plinth import __main__ as plinth_main +from unittest.mock import (patch, MagicMock) -from ..forms import ConfigurationForm +from plinth import __main__ as plinth_main +from plinth.modules.apache import uws_usr2dir +from plinth.modules.config import (home_page_url2scid, get_home_page, + _home_page_scid2url, change_home_page) +from plinth.modules.config.forms import ConfigurationForm def test_hostname_field(): @@ -64,6 +69,111 @@ def test_domainname_field(): assert not form.is_valid() +def test_homepage_mapping(): + """Basic tests for homepage functions.""" + f = home_page_url2scid + assert f(None) is None + assert f('/unknown/url') is None + assert 'plinth' == f('/plinth/') + assert 'plinth' == f('/plinth') + assert 'plinth' == f('plinth') + assert 'apache-default' == f('/index.html') + assert 'uws-user' == f('/~user') + assert 'uws-user' == f('/~user/whatever/else') + # assert 'config' == f('/plinth/apps/sharing/') + + f = _home_page_scid2url + assert f(None) is None + assert '/plinth/' == f('plinth') + assert '/index.html' == f('apache-default') + + +def test_homepage_mapping_skip_ci(): + """Special tests for homepage functions.""" + + try: + UWS_DIRECTORY = uws_usr2dir(os.getlogin()) + except OSError: + reason = "Needs access to ~/ directory. " \ + + "CI sandboxed workspace doesn't provide it." + pytest.skip(reason) + + if os.path.exists(UWS_DIRECTORY): + reason = "UWS dir {} exists already.".format(UWS_DIRECTORY) + pytest.skip(reason) + + f = _home_page_scid2url + os.mkdir(UWS_DIRECTORY) + assert '/~fbx/' == f('uws-fbx') + os.rmdir(UWS_DIRECTORY) + assert f('uws-fbx') is None + + +class Dict2Obj(object): + """Mock object made out of any dict.""" + + def __init__(self, a_dict): + self.__dict__ = a_dict + + +@patch('plinth.frontpage.Shortcut.list', MagicMock(return_value=[ + Dict2Obj({'url': 'url/for/'+id, 'component_id': id}) + for id in ('a', 'b') + ])) +def test_homepage_field(): + """Test homepage changes. + + Test Cases: + 1) FreedomBox Homepage (default), + 2) Apache default, + 3) A user's website of an... + 3.1) unexisting user + 3.2) existing user without a page + 3.3) existing user page. + 4) A FreedomBox App. + 4.1) unknown app + 4.2) uninstalled app + 4.3) disabled app + 4.4) enabled app + + Note: If run on a pristine unconfigured FreedomBox, this test will leave + the homepage default-configured. (Imperfect cleanup in such case). + + Pending: Specific test cases to distiguish 4.1,2,3. + Currently they share the same test case. + """ + try: + UWS_DIRECTORY = uws_usr2dir(os.getlogin()) + except OSError: + reason = "Needs access to ~/ directory, etc. " \ + + "CI sandboxed workspace doesn't provide it." + pytest.skip(reason) + + DEFAULT_HOME_PAGE = 'plinth' + ORIGINAL_HOME_PAGE = get_home_page() or DEFAULT_HOME_PAGE + + if ORIGINAL_HOME_PAGE not in (DEFAULT_HOME_PAGE, None): + reason = "Unexpected home page {}.".format(ORIGINAL_HOME_PAGE) + pytest.skip(reason) + + # invalid changes fall back to default: + for scid in ('uws-unexisting', 'uws-fbx', 'missing_app'): + change_home_page(scid) + assert get_home_page() == DEFAULT_HOME_PAGE + + os.mkdir(UWS_DIRECTORY) + + # valid changes actually happen: + for scid in ('b', 'a', 'uws-fbx', 'apache-default', 'plinth'): + change_home_page(scid) + assert get_home_page() == scid + + # cleanup: + change_home_page(ORIGINAL_HOME_PAGE) + os.rmdir(UWS_DIRECTORY) + assert get_home_page() == ORIGINAL_HOME_PAGE + + def test_locale_path(): """ Test that the 'locale' directory is in the same folder as __main__.py. diff --git a/plinth/tests/test_utils.py b/plinth/tests/test_utils.py index 491f9ad8d..b651cf3aa 100644 --- a/plinth/tests/test_utils.py +++ b/plinth/tests/test_utils.py @@ -10,11 +10,26 @@ import pytest import ruamel.yaml from django.test.client import RequestFactory -from plinth.utils import YAMLFile, is_user_admin +from plinth.utils import YAMLFile, is_user_admin, is_valid_user_name + + +def test_is_valid_user_name(): + """Test valid user names in Debian.""" + f = is_valid_user_name + assert not f('this_user_name_is_too_long_to_be_valid') + assert not f('-invalid') + assert not f('not\tvalid') + assert not f('not\nvalid') + assert not f('not valid') + assert not f('not:valid') + assert not f('not/valid') + assert not f('not\\valid') + assert f('€.;#@|') class TestIsAdminUser: """Test class for is_user_admin utility.""" + @staticmethod @pytest.fixture(name='web_request') def fixture_web_request(): diff --git a/plinth/utils.py b/plinth/utils.py index 9ee082fc9..5281e2e3c 100644 --- a/plinth/utils.py +++ b/plinth/utils.py @@ -58,6 +58,24 @@ def user_group_view(func, group_name): return func +def is_valid_user_name(user_name): + """Check if the given username is valid. + + Note: Debian is VERY flexible with user names. + """ + if 32 < len(user_name): + return False + + if user_name.startswith('-'): + return False + + for forbidden in (' \n\t/\\:'): + if forbidden in user_name: + return False + + return True + + def is_user_admin(request, cached=False): """Return whether user is an administrator.""" if not request.user.is_authenticated: