diff --git a/docs/source/usage/2_config.rst b/docs/source/usage/2_config.rst index 3bbe1cd..b07b93f 100644 --- a/docs/source/usage/2_config.rst +++ b/docs/source/usage/2_config.rst @@ -89,7 +89,7 @@ Environmental variables If defined - the built-in static-file serving is disabled. Use this if in production and a `proxy like nginx `_ is in front of the Ansible-WebUI webservice. - Path to serve: :code:`${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/` + Path to serve: :code:`/static/ => ${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/` * **AW_DB** diff --git a/docs/source/usage/security.rst b/docs/source/usage/security.rst index e5f01ff..bc852a6 100644 --- a/docs/source/usage/security.rst +++ b/docs/source/usage/security.rst @@ -60,6 +60,6 @@ Setup * serve static files using the proxy - :code:`${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/` + :code:`/static/ => ${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/` * Make sure the Account passwords and API keys are kept/used safe diff --git a/src/ansible-webui/aw/api.py b/src/ansible-webui/aw/api.py index 9b91f93..b63b348 100644 --- a/src/ansible-webui/aw/api.py +++ b/src/ansible-webui/aw/api.py @@ -4,6 +4,7 @@ from aw.api_endpoints.key import APIKey, APIKeyItem from aw.api_endpoints.job import APIJob, APIJobItem, APIJobExecutionItem, APIJobExecutionLogs from aw.api_endpoints.permission import APIPermission, APIPermissionItem +from aw.api_endpoints.filesystem import APIFsBrowse urlpatterns_api = [ path('api/key/', APIKeyItem.as_view()), @@ -14,6 +15,7 @@ path('api/job', APIJob.as_view()), path('api/permission/', APIPermissionItem.as_view()), path('api/permission', APIPermission.as_view()), + path('api/fs/browse/', APIFsBrowse.as_view()), path('api/_schema/', SpectacularAPIView.as_view(), name='_schema'), path('api/_docs', SpectacularSwaggerView.as_view(url_name='_schema'), name='swagger-ui'), ] diff --git a/src/ansible-webui/aw/api_endpoints/filesystem.py b/src/ansible-webui/aw/api_endpoints/filesystem.py new file mode 100644 index 0000000..c36f376 --- /dev/null +++ b/src/ansible-webui/aw/api_endpoints/filesystem.py @@ -0,0 +1,70 @@ +from os import listdir +from os import path as os_path +from pathlib import Path + +from rest_framework.views import APIView +from rest_framework import serializers +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from aw.config.main import config +from aw.api_endpoints.base import API_PERMISSION, BaseResponse, GenericResponse + + +class FileSystemReadResponse(BaseResponse): + files = serializers.ListSerializer(child=serializers.CharField()) + directories = serializers.ListSerializer(child=serializers.CharField()) + + +class APIFsBrowse(APIView): + http_method_names = ['get'] + serializer_class = FileSystemReadResponse + permission_classes = API_PERMISSION + BROWSE_SELECTORS = { + 'playbook_file': config['path_play'], + 'inventory_file': config['path_play'], + } + + @classmethod + @extend_schema( + request=None, + responses={ + 200: FileSystemReadResponse, + 400: OpenApiResponse(GenericResponse, description='Invalid browse-selector provided'), + 403: OpenApiResponse(GenericResponse, description='Traversal not allowed'), + 404: OpenApiResponse(GenericResponse, description='Base directory does not exist'), + }, + summary='Return list of existing files and directories.', + description="This endpoint is mainly used for form auto-completion when selecting job-files. " + f"Available selectors are: {BROWSE_SELECTORS}" + ) + def get(cls, request, selector: str): + if selector not in cls.BROWSE_SELECTORS: + return Response(data={'msg': 'Invalid browse-selector provided'}, status=400) + + if 'base' not in request.GET: + base = '/' + else: + base = str(request.GET['base']) + + if not base.startswith('/'): + base = f'/{base}' + + if base.find('..') != -1: + return Response(data={'msg': 'Traversal not allowed'}, status=403) + + path_check = cls.BROWSE_SELECTORS[selector] + base + if not Path(path_check).is_dir(): + return Response(data={'msg': 'Base directory does not exist'}, status=404) + + items = {'files': [], 'directories': []} + raw_items = listdir(path_check) + + for item in raw_items: + item_path = Path(os_path.join(path_check, item)) + if item_path.is_file(): + items['files'].append(item) + elif item_path.is_dir(): + items['directories'].append(item) + + return Response(items) diff --git a/src/ansible-webui/aw/config/form_metadata.py b/src/ansible-webui/aw/config/form_metadata.py index 2513b93..1462e57 100644 --- a/src/ansible-webui/aw/config/form_metadata.py +++ b/src/ansible-webui/aw/config/form_metadata.py @@ -20,18 +20,22 @@ 'tags_skip': 'Skip Tags', } } - } + }, + 'settings': { + 'permissions': {}, + }, } FORM_HELP = { 'jobs': { 'manage': { 'job': { - 'playbook': f"Playbook to execute. Search path: '{config['path_play']}'", - 'inventory': 'One or multiple inventory files/directories to include for the execution. ' - 'Comma-separated list. For details see: ' - '' - 'Ansible Docs - Inventory', + 'playbook_file': f"Playbook to execute. Search path: '{config['path_play']}'", + 'inventory_file': 'One or multiple inventory files/directories to include for the execution. ' + 'Comma-separated list. For details see: ' + '' + 'Ansible Docs - Inventory', 'limit': 'Ansible inventory hosts or groups to limit the execution to.' 'For details see: ' '' @@ -55,5 +59,8 @@ 'Ansible Docs - Check Mode', } } - } + }, + 'settings': { + 'permissions': {}, + }, } diff --git a/src/ansible-webui/aw/execute/play_util.py b/src/ansible-webui/aw/execute/play_util.py index 7cbec8e..f79add3 100644 --- a/src/ansible-webui/aw/execute/play_util.py +++ b/src/ansible-webui/aw/execute/play_util.py @@ -115,8 +115,8 @@ def runner_prep(job: Job, execution: JobExecution, path_run: Path) -> dict: update_execution_status(execution, status='Starting') opts = _runner_options(job=job, execution=execution, path_run=path_run) - opts['playbook'] = job.playbook - opts['inventory'] = job.inventory.split(',') + opts['playbook'] = job.playbook_file + opts['inventory'] = job.inventory_file.split(',') # https://docs.ansible.com/ansible/2.8/user_guide/playbooks_best_practices.html#directory-layout project_dir = opts['project_dir'] diff --git a/src/ansible-webui/aw/model/job.py b/src/ansible-webui/aw/model/job.py index d1603ab..5ad65d0 100644 --- a/src/ansible-webui/aw/model/job.py +++ b/src/ansible-webui/aw/model/job.py @@ -155,7 +155,7 @@ def validate_cronjob(value): class Job(BaseJob): CHANGE_FIELDS = [ - 'name', 'inventory', 'playbook', 'schedule', 'enabled', 'limit', 'verbosity', 'mode_diff', + 'name', 'inventory_file', 'playbook_file', 'schedule', 'enabled', 'limit', 'verbosity', 'mode_diff', 'mode_check', 'tags', 'tags_skip', 'verbosity', 'comment', 'environment_vars', 'cmd_args', 'vault_id', 'vault_file', 'connect_user', 'become_user', ] @@ -166,15 +166,15 @@ class Job(BaseJob): api_fields_write.extend(['vault_pass', 'become_pass', 'connect_pass']) name = models.CharField(max_length=150) - inventory = models.CharField(max_length=300) # NOTE: one or multiple comma-separated inventories - playbook = models.CharField(max_length=100) + inventory_file = models.CharField(max_length=300) # NOTE: one or multiple comma-separated inventories + playbook_file = models.CharField(max_length=100) schedule_max_len = 50 schedule = models.CharField(max_length=schedule_max_len, validators=[validate_cronjob], blank=True, default=None) enabled = models.BooleanField(choices=CHOICES_BOOL, default=True) def __str__(self) -> str: limit = '' if self.limit is None else f' [{self.limit}]' - return f"Job '{self.name}' ({self.playbook} => {self.inventory}{limit})" + return f"Job '{self.name}' ({self.playbook_file} => {self.inventory_file}{limit})" class Meta: constraints = [ diff --git a/src/ansible-webui/aw/static/css/aw.css b/src/ansible-webui/aw/static/css/aw.css index 1fa592b..69769ff 100644 --- a/src/ansible-webui/aw/static/css/aw.css +++ b/src/ansible-webui/aw/static/css/aw.css @@ -341,6 +341,10 @@ form .form-control { width: 20vw; } +input:invalid { + border-color: red; +} + .mb-3 { margin-bottom: 0.1rem !important; } diff --git a/src/ansible-webui/aw/static/js/aw.js b/src/ansible-webui/aw/static/js/aw.js index f32f799..9872c76 100644 --- a/src/ansible-webui/aw/static/js/aw.js +++ b/src/ansible-webui/aw/static/js/aw.js @@ -81,7 +81,7 @@ function apiActionSuccess(result) { // todo: fix success message not showing after refresh reloadAwData(); - resultDiv = document.getElementById('aw-api-result'); + resultDiv = document.getElementById('aw-api-result'); if (result.msg) { resultDiv.innerHTML = 'Success: ' + result.msg; } else { @@ -96,7 +96,7 @@ function apiActionError(result, exception) { console.log(exception); // write full/verbose error message to hidden iframe (could be full html response) that can be toggled by user - errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document; + errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document; errorFullIframe.open(); errorFullIframe.write('

FULL ERROR:


' + result.responseText); errorFullIframe.close(); @@ -112,7 +112,7 @@ function apiActionError(result, exception) { } function apiActionErrorClear() { - errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document; + errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document; errorFullIframe.open(); errorFullIframe.write(''); errorFullIframe.close(); @@ -128,6 +128,52 @@ function apiActionFullError() { document.getElementById("aw-api-error-full").scrollIntoView(); } +function apiBrowseDirUpdateChoices(inputElement, choicesElement, searchType, result) { + console.log(result); + choices = result[searchType]; + inputElement.attr("pattern", '(.*\\/|^)(' + choices.join('|') + ')$'); + let title = ""; + if (choices.length == 0) { + title += "No available " + searchType + " found." + } else if (choices.length > 0) { + title += "You might choose one of the existing " + searchType + ": '" + choices.join(', ') + "'"; + } + dirs = result['directories']; + if (searchType != 'directories' && dirs.length > 0) { + title += " Available directories: '" + dirs.join(', ') + "'"; + } + + inputElement.attr("title", title); + + choicesElement.innerHTML = "" + for (i = 0, len = choices.length; i < len; i++) { + let choice = choices[i]; + choicesElement.innerHTML += "
  • " + choice + "
  • "; + } + if (searchType != 'directories') { + for (i = 0, len = dirs.length; i < len; i++) { + let dir = dirs[i]; + choicesElement.innerHTML += '
  • ' + dir + "
  • "; + } + } +} + +function apiBrowseDirRemoveChoices(inputElement, choicesElement, searchType, exception) { + console.log(exception); + inputElement.attr("pattern", '^\\b$'); + inputElement.attr("title", "You need to choose one of the existing " + searchType); +} + +function apiBrowseDir(inputElement, choicesElement, selector, base, searchType) { + console.log("/api/fs/browse/" + selector + "?base=" + base + ' | searching for ' + searchType); + $.ajax({ + url: "/api/fs/browse/" + selector + "?base=" + base, + type: "GET", + success: function (result) { apiBrowseDirUpdateChoices(inputElement, choicesElement, searchType, result); }, + error: function (exception) { apiBrowseDirRemoveChoices(inputElement, choicesElement, searchType, exception); }, + }); +} + const csrf_token = getCookie('csrftoken'); // EVENTHANDLER @@ -196,4 +242,26 @@ $( document ).ready(function() { return false; }); + $(".aw-main").on("input", ".aw-fs-browse", function(){ + let searchType = $(this).attr("aw-fs-type"); + let apiSelector = $(this).attr("aw-fs-selector"); + let apiChoices = $(this).attr("aw-fs-choices"); + + // check if highest level of user-input is in choices; go to next depth + let userInput = $(this).val(); + if (typeof(userInput) == 'undefined' || userInput == null) { + userInput = ""; + } + + userInputLevels = userInput.split('/'); + selected = userInputLevels.pop(); + apiBase = userInputLevels.join('/'); + + apiChoicesElement = document.getElementById(apiChoices); + if (this.checkValidity() == false) { + apiBrowseDir(jQuery(this), apiChoicesElement, apiSelector, apiBase, searchType); + } else { + apiChoicesElement.innerHTML = "" + } + }); }); diff --git a/src/ansible-webui/aw/templates/head.html b/src/ansible-webui/aw/templates/head.html index 9445c17..92bf615 100644 --- a/src/ansible-webui/aw/templates/head.html +++ b/src/ansible-webui/aw/templates/head.html @@ -11,9 +11,6 @@ -{% if request.user_agent.is_mobile %} - -{% endif %} diff --git a/src/ansible-webui/aw/templates/jobs/manage.html b/src/ansible-webui/aw/templates/jobs/manage.html index 926a426..35e7ad8 100644 --- a/src/ansible-webui/aw/templates/jobs/manage.html +++ b/src/ansible-webui/aw/templates/jobs/manage.html @@ -2,7 +2,6 @@ {% load static %} {% load util %} {% block content %} -{% load user_agents %} {# todo: build table using ajax to allow dynamic search #}
    diff --git a/src/ansible-webui/aw/templates/jobs/manage_job.html b/src/ansible-webui/aw/templates/jobs/manage_job.html index 0ae9088..d24c67e 100644 --- a/src/ansible-webui/aw/templates/jobs/manage_job.html +++ b/src/ansible-webui/aw/templates/jobs/manage_job.html @@ -4,8 +4,8 @@ {% set_var executions|get_value:job.id as job_executions %} - - + +
    {{ job.name }}{{ job.inventory }}{{ job.playbook }}{{ job.inventory_file }}{{ job.playbook_file }} {{ job.comment|get_fallback:"-" }} {{ job.schedule|get_fallback:"-" }} {% if job.schedule|exists and not job.enabled %} diff --git a/src/ansible-webui/aw/templates/settings/environment.html b/src/ansible-webui/aw/templates/system/environment.html similarity index 100% rename from src/ansible-webui/aw/templates/settings/environment.html rename to src/ansible-webui/aw/templates/system/environment.html diff --git a/src/ansible-webui/aw/templatetags/form_util.py b/src/ansible-webui/aw/templatetags/form_util.py index a771061..585b0c2 100644 --- a/src/ansible-webui/aw/templatetags/form_util.py +++ b/src/ansible-webui/aw/templatetags/form_util.py @@ -88,10 +88,19 @@ def get_form_field_select(bf: BoundField, existing: dict) -> str: @register.filter def get_form_field_input(bf: BoundField, existing: dict) -> str: - field_type = '' - if bf.name.find('pass') != -1: - field_type = 'type="password" ' - - return (f'' + + return (f'') + f'{get_form_field_attributes(bf)} {get_form_field_validators(bf)}>' + f'{search_choices}') diff --git a/src/ansible-webui/aw/templatetags/job_util.py b/src/ansible-webui/aw/templatetags/job_util.py index b7114f2..a4990dc 100644 --- a/src/ansible-webui/aw/templatetags/job_util.py +++ b/src/ansible-webui/aw/templatetags/job_util.py @@ -5,6 +5,7 @@ from aw.model.job import JobExecution, CHOICES_JOB_EXEC_STATUS from aw.config.hardcoded import SHORT_TIME_FORMAT from aw.utils.util import datetime_from_db, is_null +from aw.utils.permission import get_permission_name register = template.Library() @@ -98,3 +99,8 @@ def execution_logfile_exists(execution: JobExecution, attr: str = 'log_stdout') return False return Path(log_attr).is_file() + + +@register.filter +def permission_name(perm: int) -> str: + return get_permission_name(perm)