diff --git a/server/src/uds/REST/__init__.py b/server/src/uds/REST/__init__.py index c802a5431..f6d1b5472 100644 --- a/server/src/uds/REST/__init__.py +++ b/server/src/uds/REST/__init__.py @@ -31,5 +31,6 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com """ # pyright: reportUnusedImport=false # Convenience imports, must be present before initializing handlers -from .handlers import Handler +from .handlers import Handler, HelpPath from .dispatcher import Dispatcher +from .documentation import Documentation \ No newline at end of file diff --git a/server/src/uds/REST/dispatcher.py b/server/src/uds/REST/dispatcher.py index e73f58f42..2c68f26f6 100644 --- a/server/src/uds/REST/dispatcher.py +++ b/server/src/uds/REST/dispatcher.py @@ -46,7 +46,7 @@ from uds.core.util import modfinder from . import processors, log from .handlers import Handler -from .model import DetailHandler +from .model import DetailHandler, ModelHandler # Not imported at runtime, just for type checking if typing.TYPE_CHECKING: @@ -65,7 +65,8 @@ class HandlerNode: name: str handler: typing.Optional[type[Handler]] - children: collections.abc.MutableMapping[str, 'HandlerNode'] + parent: typing.Optional['HandlerNode'] + children: dict[str, 'HandlerNode'] def __str__(self) -> str: return f'HandlerNode({self.name}, {self.handler}, {self.children})' @@ -77,10 +78,50 @@ class HandlerNode: """ Returns a string representation of the tree """ - ret = f'{" " * level}{self.name} ({self.handler.__name__ if self.handler else "None"})\n' - for child in self.children.values(): - ret += child.tree(level + 1) - return ret + if self.handler is None: + return f'{" " * level}|- {self.name}\n' + ''.join( + child.tree(level + 1) for child in self.children.values() + ) + + ret = f'{" " * level}{self.name} ({self.handler.__name__} {self.full_path()})\n' + + if issubclass(self.handler, ModelHandler): + # Add custom_methods + for method in self.handler.custom_methods: + ret += f'{" " * level} |- {method}\n' + # Add detail methods + if self.handler.detail: + for method in self.handler.detail.keys(): + ret += f'{" " * level} |- {method}\n' + + return ret + ''.join(child.tree(level + 1) for child in self.children.values()) + + def find_path(self, path: str | list[str]) -> typing.Optional['HandlerNode']: + """ + Returns the node for a given path, or None if not found + """ + if not path or not self.children: + return self + path = path.split('/') if isinstance(path, str) else path + + if path[0] not in self.children: + return None + + return self.children[path[0]].find_path(path[1:]) # Recursive call + + def full_path(self) -> str: + """ + Returns the full path of this node + """ + if self.name == '' or self.parent is None: + return '' + + parent_full_path = self.parent.full_path() + + if parent_full_path == '': + return self.name + + return f'{parent_full_path}/{self.name}' class Dispatcher(View): @@ -89,7 +130,7 @@ class Dispatcher(View): """ # This attribute will contain all paths--> handler relations, filled at Initialized method - services: typing.ClassVar[HandlerNode] = HandlerNode('', None, {}) + base_handler_node: typing.ClassVar[HandlerNode] = HandlerNode('', None, None, {}) @method_decorator(csrf_exempt) def dispatch( @@ -103,35 +144,40 @@ class Dispatcher(View): del request.session # Now we extract method and possible variables from path - path: list[str] = kwargs['arguments'].split('/') + # path: list[str] = kwargs['arguments'].split('/') + path = kwargs['arguments'] del kwargs['arguments'] - # Transverse service nodes, so we can locate class processing this path - service = Dispatcher.services - full_path_lst: list[str] = [] - # Guess content type from content type header (post) or ".xxx" to method + # # Transverse service nodes, so we can locate class processing this path + # service = Dispatcher.services + # full_path_lst: list[str] = [] + # # Guess content type from content type header (post) or ".xxx" to method content_type: str = request.META.get('CONTENT_TYPE', 'application/json').split(';')[0] - while path: - clean_path = path[0] - # Skip empty path elements, so /x/y == /x////y for example (due to some bugs detected on some clients) - if not clean_path: - path = path[1:] - continue + # while path: + # clean_path = path[0] + # # Skip empty path elements, so /x/y == /x////y for example (due to some bugs detected on some clients) + # if not clean_path: + # path = path[1:] + # continue - if clean_path in service.children: # if we have a node for this path, walk down - service = service.children[clean_path] - full_path_lst.append(path[0]) # Add this path to full path - path = path[1:] # Remove first part of path - else: - break # If we don't have a node for this path, we are done + # if clean_path in service.children: # if we have a node for this path, walk down + # service = service.children[clean_path] + # full_path_lst.append(path[0]) # Add this path to full path + # path = path[1:] # Remove first part of path + # else: + # break # If we don't have a node for this path, we are done - full_path = '/'.join(full_path_lst) - logger.debug("REST request: %s (%s)", full_path, content_type) + # full_path = '/'.join(full_path_lst) + handler_node = Dispatcher.base_handler_node.find_path(path) + if not handler_node: + return http.HttpResponseNotFound('Service not found', content_type="text/plain") + + logger.debug("REST request: %s (%s)", handler_node, handler_node.full_path()) # Now, service points to the class that will process the request # We get the '' node, that is the "current" node, and get the class from it - cls: typing.Optional[type[Handler]] = service.handler + cls: typing.Optional[type[Handler]] = handler_node.handler if not cls: return http.HttpResponseNotFound('Method not found', content_type="text/plain") @@ -146,14 +192,14 @@ class Dispatcher(View): return http.HttpResponseNotAllowed(['GET', 'POST', 'PUT', 'DELETE'], content_type="text/plain") # Path here has "remaining" path, that is, method part has been removed - args = tuple(path) + args = path[len(handler_node.full_path()):].split('/')[1:] # First element is always empty, so we skip it handler: typing.Optional[Handler] = None try: handler = cls( request, - full_path, + handler_node.full_path(), http_method, processor.process_parameters(), *args, @@ -161,12 +207,12 @@ class Dispatcher(View): ) operation: collections.abc.Callable[[], typing.Any] = getattr(handler, http_method) except processors.ParametersException as e: - logger.debug('Path: %s', full_path) + logger.debug('Path: %s', ) logger.debug('Error: %s', e) log.log_operation(handler, 400, types.log.LogLevel.ERROR) return http.HttpResponseBadRequest( - f'Invalid parameters invoking {full_path}: {e}', + f'Invalid parameters invoking {handler_node.full_path()}: {e}', content_type="text/plain", ) except AttributeError: @@ -179,7 +225,7 @@ class Dispatcher(View): except Exception: log.log_operation(handler, 500, types.log.LogLevel.ERROR) logger.exception('error accessing attribute') - logger.debug('Getting attribute %s for %s', http_method, full_path) + logger.debug('Getting attribute %s for %s', http_method, handler_node.full_path()) return http.HttpResponseServerError('Unexcepected error', content_type="text/plain") # Invokes the handler's operation, add headers to response and returns @@ -198,7 +244,7 @@ class Dispatcher(View): ), ) else: - response = processor.get_response(response) + response = processor.get_response(response) # Set response headers response['UDS-Version'] = f'{consts.system.VERSION};{consts.system.VERSION_STAMP}' for k, val in handler.headers().items(): @@ -230,7 +276,7 @@ class Dispatcher(View): log.log_operation(handler, 500, types.log.LogLevel.ERROR) # Get ecxeption backtrace trace_back = traceback.format_exc() - logger.error('Exception processing request: %s', full_path) + logger.error('Exception processing request: %s', handler_node.full_path()) for i in trace_back.splitlines(): logger.error('* %s', i) @@ -248,20 +294,20 @@ class Dispatcher(View): name = type_.name # Fill the service_node tree with the class - service_node = Dispatcher.services # Root path + service_node = Dispatcher.base_handler_node # Root path # If path, ensure that the path exists on the tree if type_.path: logger.info('Path: /%s/%s', type_.path, name) for k in type_.path.split('/'): intern_k = sys.intern(k) if intern_k not in service_node.children: - service_node.children[intern_k] = HandlerNode(k, None, {}) + service_node.children[intern_k] = HandlerNode(k, None, service_node, {}) service_node = service_node.children[intern_k] else: logger.info('Path: /%s', name) if name not in service_node.children: - service_node.children[name] = HandlerNode(name, None, {}) + service_node.children[name] = HandlerNode(name, None, service_node, {}) service_node.children[name] = dataclasses.replace(service_node.children[name], handler=type_) diff --git a/server/src/uds/REST/documentation.py b/server/src/uds/REST/documentation.py new file mode 100644 index 000000000..b129f9ca7 --- /dev/null +++ b/server/src/uds/REST/documentation.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2025 Virtual Cable S.L.U. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Author: Adolfo Gómez, dkmaster at dkmon dot com +""" +import logging +import typing + +from django import http +from django.views.generic.base import View + +from .dispatcher import Dispatcher + +# Not imported at runtime, just for type checking +if typing.TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class Documentation(View): + + def dispatch( + self, request: 'http.request.HttpRequest', *_args: typing.Any, **kwargs: typing.Any + ) -> 'http.HttpResponse': + service = Dispatcher.base_handler_node + + return http.HttpResponseServerError(f'{service.tree()}', content_type="text/plain") diff --git a/server/src/uds/REST/handlers.py b/server/src/uds/REST/handlers.py index bc55476a5..224f6bccb 100644 --- a/server/src/uds/REST/handlers.py +++ b/server/src/uds/REST/handlers.py @@ -52,6 +52,12 @@ if typing.TYPE_CHECKING: logger = logging.getLogger(__name__) +class HelpPath(typing.NamedTuple): + """ + Help path class + """ + path: str + help: str class Handler: """ @@ -74,7 +80,7 @@ class Handler: # For implementing help # A list of pairs of (path, help) for subpaths on this handler - help_paths: typing.ClassVar[list[tuple[str, str]]] = [] + help_paths: typing.ClassVar[list[HelpPath]] = [] help_text: typing.ClassVar[str] = 'No help available' _request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest diff --git a/server/src/uds/REST/methods/stats.py b/server/src/uds/REST/methods/stats.py index 851cf808b..a8887f4f3 100644 --- a/server/src/uds/REST/methods/stats.py +++ b/server/src/uds/REST/methods/stats.py @@ -35,7 +35,7 @@ import datetime import typing from uds.core import types -from uds.REST import Handler +from uds.REST import Handler, HelpPath from uds import models from uds.core.util.stats import counters @@ -48,7 +48,7 @@ class Stats(Handler): needs_admin = True help_paths = [ - ('', 'Returns the last day usage statistics for all authenticators'), + HelpPath('', 'Returns the last day usage statistics for all authenticators'), ] help_text = 'Provides access to usage statistics' diff --git a/server/src/uds/REST/methods/system.py b/server/src/uds/REST/methods/system.py index 7166418de..c31127cf5 100644 --- a/server/src/uds/REST/methods/system.py +++ b/server/src/uds/REST/methods/system.py @@ -44,7 +44,7 @@ from uds.core.util.cache import Cache from uds.core.util.model import process_uuid, sql_now from uds.core.types.states import State from uds.core.util.stats import counters -from uds.REST import Handler +from uds.REST import Handler, HelpPath logger = logging.getLogger(__name__) @@ -147,15 +147,15 @@ class System(Handler): needs_staff = True help_paths = [ - ('', ''), - ('stats/assigned', ''), - ('stats/inuse', ''), - ('stats/cached', ''), - ('stats/complete', ''), - ('stats/assigned/', ''), - ('stats/inuse/', ''), - ('stats/cached/', ''), - ('stats/complete/', ''), + HelpPath('', ''), + HelpPath('stats/assigned', ''), + HelpPath('stats/inuse', ''), + HelpPath('stats/cached', ''), + HelpPath('stats/complete', ''), + HelpPath('stats/assigned/', ''), + HelpPath('stats/inuse/', ''), + HelpPath('stats/cached/', ''), + HelpPath('stats/complete/', ''), ] help_text = 'Provides system information. Must be admin to access this' diff --git a/server/src/uds/admin/views/__init__.py b/server/src/uds/admin/views/__init__.py index 9f85320c8..fbce2f906 100644 --- a/server/src/uds/admin/views/__init__.py +++ b/server/src/uds/admin/views/__init__.py @@ -34,7 +34,6 @@ import logging from django.http import HttpResponse from django.middleware import csrf from django.shortcuts import render -from django.template import RequestContext, loader from django.utils.translation import gettext as _ from uds.core import consts @@ -46,7 +45,7 @@ if typing.TYPE_CHECKING: from django.http import HttpRequest -@weblogin_required(admin=True) +@weblogin_required(role=consts.Roles.ADMIN) def index(request: 'HttpRequest') -> HttpResponse: # Gets csrf token csrf_token = csrf.get_token(request) @@ -57,19 +56,14 @@ def index(request: 'HttpRequest') -> HttpResponse: {'csrf_field': consts.auth.CSRF_FIELD, 'csrf_token': csrf_token}, ) - -@weblogin_required(admin=True) -def tmpl(request: 'HttpRequest', template: str) -> HttpResponse: - try: - t = loader.get_template('uds/admin/tmpl/' + template + ".html") - c = RequestContext(request) - resp = t.render(c.flatten()) - except Exception as e: - logger.debug('Exception getting template: %s', e) - resp = _('requested a template that do not exist') - return HttpResponse(resp, content_type="text/plain") - - -@weblogin_required(admin=True) -def sample(request: 'HttpRequest') -> HttpResponse: - return render(request, 'uds/admin/sample.html') +# from django.template import RequestContext, loader +# @weblogin_required(role=consts.Roles.ADMIN) +# def tmpl(request: 'HttpRequest', template: str) -> HttpResponse: +# try: +# t = loader.get_template('uds/admin/tmpl/' + template + ".html") +# c = RequestContext(request) +# resp = t.render(c.flatten()) +# except Exception as e: +# logger.debug('Exception getting template: %s', e) +# resp = _('requested a template that do not exist') +# return HttpResponse(resp, content_type="text/plain") diff --git a/server/src/uds/core/auths/auth.py b/server/src/uds/core/auths/auth.py index 69ba7934f..b82d5034e 100644 --- a/server/src/uds/core/auths/auth.py +++ b/server/src/uds/core/auths/auth.py @@ -117,17 +117,14 @@ def root_user() -> models.User: # Decorator to make easier protect pages that needs to be logged in def weblogin_required( - admin: typing.Union[bool, typing.Literal['admin']] = False + role: typing.Optional[consts.Roles] = None, ) -> collections.abc.Callable[ [collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse] ]: """Decorator to set protection to access page - Look for samples at uds.core.web.views - if admin == True, needs admin or staff - if admin == 'admin', needs admin - + Args: - admin (bool, optional): If True, needs admin or staff. Is it's "admin" literal, needs admin . Defaults to False (any user). + role (str, optional): If set, needs this role. Defaults to None. Returns: collections.abc.Callable[[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]]: Decorator @@ -135,6 +132,7 @@ def weblogin_required( Note: This decorator is used to protect pages that needs to be logged in. To protect against ajax calls, use `denyNonAuthenticated` instead + Roles as "inclusive", that is, if you set role to USER, it will allow all users that are not anonymous. (USER, STAFF, ADMIN) """ def decorator( @@ -151,8 +149,8 @@ def weblogin_required( if not request.user or not request.authorized: return weblogout(request) - if admin in (True, 'admin'): - if request.user.is_staff() is False or (admin == 'admin' and not request.user.is_admin): + if role in (consts.Roles.ADMIN, consts.Roles.STAFF): + if request.user.is_staff() is False or (role == consts.Roles.ADMIN and not request.user.is_admin): return HttpResponseForbidden(_('Forbidden')) return view_func(request, *args, **kwargs) diff --git a/server/src/uds/core/consts/__init__.py b/server/src/uds/core/consts/__init__.py index 367e73392..1ac8bec51 100644 --- a/server/src/uds/core/consts/__init__.py +++ b/server/src/uds/core/consts/__init__.py @@ -31,6 +31,7 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com """ # pyright: reportUnusedImport=false +import enum import time import typing from datetime import datetime @@ -74,3 +75,16 @@ UNLIMITED: typing.Final[int] = -1 # Constant marking no more names available NO_MORE_NAMES: typing.Final[str] = 'NO-NAME-ERROR' + + +class Roles(enum.StrEnum): + """ + Roles for users + """ + + ADMIN = 'admin' + STAFF = 'staff' + + # Currently not used, but reserved + USER = 'user' + ANONYMOUS = 'anonymous' diff --git a/server/src/uds/urls.py b/server/src/uds/urls.py index 568d37f35..241aa750d 100644 --- a/server/src/uds/urls.py +++ b/server/src/uds/urls.py @@ -267,6 +267,12 @@ urlpatterns = [ custom.custom, name='custom', ), + # REST API documentation + re_path( + r'^uds/rest/doc/?(?P.*)$', + REST.Documentation.as_view(), + name='REST.doc', + ), # REST API re_path( r'^uds/rest/(?P.*)$', diff --git a/server/src/uds/web/views/auth.py b/server/src/uds/web/views/auth.py index 264f8e70e..a256486cc 100644 --- a/server/src/uds/web/views/auth.py +++ b/server/src/uds/web/views/auth.py @@ -350,7 +350,7 @@ def login(request: types.requests.ExtendedHttpRequest, tag: typing.Optional[str] @never_cache -@auth.weblogin_required(admin=False) +@auth.weblogin_required() def logout(request: types.requests.ExtendedHttpRequestWithUser) -> HttpResponse: auth.log_logout(request) request.session['restricted'] = False # Remove restricted diff --git a/server/src/uds/web/views/download.py b/server/src/uds/web/views/download.py index 407f3e34b..50b5e40fa 100644 --- a/server/src/uds/web/views/download.py +++ b/server/src/uds/web/views/download.py @@ -31,6 +31,7 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com import logging import typing +from uds.core import consts from uds.core.auths.auth import weblogin_required from uds.core.managers import downloads_manager from .main import index @@ -45,7 +46,7 @@ if typing.TYPE_CHECKING: logger = logging.getLogger(__name__) -@weblogin_required(admin=True) +@weblogin_required(role=consts.Roles.STAFF) def download(request: 'HttpRequest', download_id: str) -> 'HttpResponse': """ Downloadables management diff --git a/server/src/uds/web/views/main.py b/server/src/uds/web/views/main.py index 217977e06..ad331e31e 100644 --- a/server/src/uds/web/views/main.py +++ b/server/src/uds/web/views/main.py @@ -64,13 +64,13 @@ def index(request: HttpRequest) -> HttpResponse: return response -# Includes a request.session ticket, indicating that +# Launches the service using a ticket (for example, from external portal) @never_cache def ticket_launcher(request: HttpRequest) -> HttpResponse: return index(request) -# Basically, the original /login method, but fixed for modern interface +# Javascript configuration @never_cache def js(request: types.requests.ExtendedHttpRequest) -> HttpResponse: return HttpResponse(content=configjs.uds_js(request), content_type='application/javascript') diff --git a/server/src/uds/web/views/service.py b/server/src/uds/web/views/service.py index 907747384..6652304cf 100644 --- a/server/src/uds/web/views/service.py +++ b/server/src/uds/web/views/service.py @@ -60,7 +60,7 @@ if typing.TYPE_CHECKING: logger = logging.getLogger(__name__) -@weblogin_required(admin=False) +@weblogin_required() def transport_own_link( request: 'ExtendedHttpRequestWithUser', service_id: str, transport_id: str ) -> HttpResponse: @@ -105,8 +105,7 @@ def transport_own_link( return HttpResponse(content=json.dumps(response), content_type='application/json') -# pylint: disable=unused-argument -@weblogin_required(admin=False) +@weblogin_required() @never_cache def user_service_enabler( request: 'ExtendedHttpRequestWithUser', service_id: str, transport_id: str @@ -126,7 +125,7 @@ def closer(request: 'ExtendedHttpRequest') -> HttpResponse: # return HttpResponse('') -@weblogin_required(admin=False) +@weblogin_required() @never_cache def user_service_status( request: 'ExtendedHttpRequestWithUser', service_id: str, transport_id: str @@ -170,7 +169,7 @@ def user_service_status( return HttpResponse(json.dumps({'status': status}), content_type='application/json') -@weblogin_required(admin=False) +@weblogin_required() @never_cache def action(request: 'ExtendedHttpRequestWithUser', service_id: str, action_string: str) -> HttpResponse: userservice = UserServiceManager.manager().locate_meta_service(request.user, service_id)