From ea48f9a74bf51de87cc5230617e05f4ff0e11fe1 Mon Sep 17 00:00:00 2001 From: Veiko Aasa Date: Thu, 5 Dec 2019 17:13:26 +0300 Subject: [PATCH] storage: Directory selection form and validator Directory selection allows to: - select from default directory - select from available Samba shares - specify subdirectory - insert custom directory - directory validator checks: path exists, is directory, is readable, is writable - samba: action script: include share path in share list - create freedombox-share group inside users module instead of samba module Closes #1703 Signed-off-by: Veiko Aasa Reviewed-by: James Valleroy --- actions/samba | 5 +- actions/storage | 22 +++++ plinth/modules/samba/__init__.py | 3 +- plinth/modules/storage/forms.py | 161 +++++++++++++++++++++++++++++++ plinth/modules/users/__init__.py | 17 +++- 5 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 plinth/modules/storage/forms.py diff --git a/actions/samba b/actions/samba index 88fcbdcc7..5545fa52e 100755 --- a/actions/samba +++ b/actions/samba @@ -163,9 +163,10 @@ def _get_shares(): config = configparser.ConfigParser() config.read_string(output.decode()) for name in config.sections(): - mount_point = _get_mount_point(config[name]['path']) + path = config[name]['path'] + mount_point = _get_mount_point(path) mount_point = os.path.normpath(mount_point) - shares.append(dict(name=name, mount_point=mount_point)) + shares.append(dict(name=name, mount_point=mount_point, path=path)) return shares diff --git a/actions/storage b/actions/storage index d156220a4..95925708f 100755 --- a/actions/storage +++ b/actions/storage @@ -21,6 +21,7 @@ Configuration helper for disks manager. import argparse import json +import os import re import subprocess import sys @@ -51,6 +52,14 @@ def parse_arguments(): subparsers.add_parser('usage-info', help='Get information about disk space usage') + subparser = subparsers.add_parser('validate-directory', + help='Validate a directory') + subparser.add_argument('--path', help='Path of the directory', + required=True) + subparser.add_argument('--check-writable', required=False, default=False, + action='store_true', + help='Check that the directory is writable') + subparsers.required = True return parser.parse_args() @@ -316,6 +325,19 @@ def subcommand_usage_info(_): subprocess.run(command, check=True) +def subcommand_validate_directory(arguments): + """Validate a directory""" + directory = arguments.path + if not os.path.exists(directory): + print('ValidationError: 1') + if not os.path.isdir(directory): + print('ValidationError: 2') + if not os.access(directory, os.R_OK): + print('ValidationError: 3') + if arguments.check_writable and not os.access(directory, os.W_OK): + print('ValidationError: 4') + + def main(): """Parse arguments and perform all duties.""" arguments = parse_arguments() diff --git a/plinth/modules/samba/__init__.py b/plinth/modules/samba/__init__.py index 697c21e2b..9524ed27f 100644 --- a/plinth/modules/samba/__init__.py +++ b/plinth/modules/samba/__init__.py @@ -28,7 +28,7 @@ from plinth import action_utils, actions from plinth import app as app_module from plinth import frontpage, menu from plinth.daemon import Daemon -from plinth.modules.users import create_group, register_group +from plinth.modules.users import register_group from plinth.modules.firewall.components import Firewall from plinth.utils import format_lazy @@ -106,7 +106,6 @@ def init(): def setup(helper, old_version=None): """Install and configure the module.""" helper.install(managed_packages) - create_group('freedombox-share') helper.call('post', actions.superuser_run, 'samba', ['setup']) helper.call('post', app.enable) diff --git a/plinth/modules/storage/forms.py b/plinth/modules/storage/forms.py new file mode 100644 index 000000000..17a0ec7a1 --- /dev/null +++ b/plinth/modules/storage/forms.py @@ -0,0 +1,161 @@ +# +# 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 . +# +""" +Forms for directory selection. +""" + +import json +import os + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions, module_loader +from plinth.forms import AppForm +from plinth.modules import storage + + +def get_available_samba_shares(): + """Get available samba shares.""" + available_shares = [] + if is_module_enabled('samba'): + samba_shares = json.loads( + actions.superuser_run('samba', ['get-shares'])) + if samba_shares: + disks = storage.get_disks() + for share in samba_shares: + for disk in disks: + if share['mount_point'] == disk['mount_point']: + available_shares.append(share) + break + return available_shares + + +def is_module_enabled(name): + """Check whether a module is enabled.""" + if name in module_loader.loaded_modules: + module = module_loader.loaded_modules['samba'] + if module.setup_helper.get_state( + ) != 'needs-setup' and module.app.is_enabled(): + return True + + return False + + +class DirectoryValidator: + username = None + check_writable = False + add_user_to_share_group = False + service_to_restart = None + + def __init__(self, username=None, check_writable=None): + if username is not None: + self.username = username + if check_writable is not None: + self.check_writable = check_writable + + def __call__(self, value): + """Validate a directory.""" + if not value.startswith('/'): + raise ValidationError(_('Invalid directory name.'), 'invalid') + + command = ['validate-directory', '--path', value] + if self.check_writable: + command.append('--check-writable') + + if self.username: + output = actions.run_as_user('storage', command, + become_user=self.username) + else: + output = actions.run('storage', command) + + if 'ValidationError' in output: + error_nr = int(output.strip().split()[1]) + if error_nr == 1: + raise ValidationError( + _('Directory does not exist.'), 'invalid') + elif error_nr == 2: + raise ValidationError(_('Path is not a directory.'), 'invalid') + elif error_nr == 3: + raise ValidationError( + _('Directory is not readable by the user.'), 'invalid') + elif error_nr == 4: + raise ValidationError( + _('Directory is not writable by the user.'), 'invalid') + + +class DirectorySelectForm(AppForm): + """Directory selection form.""" + storage_dir = forms.ChoiceField(choices=[], label=_('Directory'), + required=True) + storage_subdir = forms.CharField( + label=_('Subdirectory (optional)'), required=False) + + def __init__(self, title=None, default='/', validator=DirectoryValidator, + *args, **kwargs): + super().__init__(*args, **kwargs) + if title: + self.fields['storage_dir'].label = title + self.validator = validator + self.default = os.path.normpath(default) + self.set_form_data() + + def clean(self): + """Clean and validate form data.""" + if self.cleaned_data['is_enabled'] or not self.initial['is_enabled']: + storage_dir = self.cleaned_data['storage_dir'] + storage_subdir = self.cleaned_data['storage_subdir'] + if storage_dir != '/': + storage_subdir = storage_subdir.lstrip('/') + storage_path = os.path.realpath( + os.path.join(storage_dir, storage_subdir)) + if self.validator: + self.validator(storage_path) + self.cleaned_data.update({'storage_path': storage_path}) + + def get_initial(self, choices): + """Get initial form data.""" + initial_selection = () + subdir = '' + storage_path = self.initial['storage_path'] + for choice in choices: + if storage_path.startswith(choice[0]): + initial_selection = choice + subdir = storage_path.split(choice[0], 1)[1].strip('/') + if choice[0] == '/': + subdir = '/' + subdir + break + return (initial_selection, subdir) + + def set_form_data(self): + """Set initial form data.""" + choices = [] + if self.default: + choices = choices + [(self.default, '{0}: {1}'.format( + _('Default'), self.default))] + available_shares = get_available_samba_shares() + for share in available_shares: + choices = choices + [(share['path'], '{0} ({1}): {2}'.format( + _('Samba share'), share['name'], share['path']))] + choices = choices + [('/', _('Other directory (specify below)'))] + + initial_value, subdir = self.get_initial(choices) + + self.fields['storage_dir'].choices = choices + self.initial['storage_dir'] = initial_value + self.initial['storage_subdir'] = subdir diff --git a/plinth/modules/users/__init__.py b/plinth/modules/users/__init__.py index b43087580..ae869049e 100644 --- a/plinth/modules/users/__init__.py +++ b/plinth/modules/users/__init__.py @@ -18,6 +18,7 @@ FreedomBox app to manage users. """ +import grp import subprocess from django.utils.translation import ugettext_lazy as _ @@ -27,7 +28,7 @@ from plinth import app as app_module from plinth import cfg, menu from plinth.utils import format_lazy -version = 2 +version = 3 is_essential = True @@ -92,6 +93,7 @@ def setup(helper, old_version=None): if not old_version: helper.call('post', actions.superuser_run, 'users', ['first-setup']) helper.call('post', actions.superuser_run, 'users', ['setup']) + create_group('freedombox-share') def diagnose(): @@ -148,3 +150,16 @@ def get_last_admin_user(): return admin_users[0] return None + + +def add_user_to_share_group(username, service=None): + """Add user to the freedombox-share group.""" + try: + group_members = grp.getgrnam('freedombox-share').gr_mem + except KeyError: + group_members = [] + if username not in group_members: + actions.superuser_run( + 'users', ['add-user-to-group', username, 'freedombox-share']) + if service: + action_utils.service_restart(service)