import datetime import logging from flask import Flask, redirect, current_app, make_response, render_template, abort from flask import Blueprint, request from flask_restful import Api, Resource from server_timing import Timing as ServerTiming from http import HTTPStatus import server.common.rest as common_rest from server.common.errors import DatasetAccessError, RequestException from server.common.utils import path_join, Float32JSONEncoder from server.common.data_locator import DataLocator from server.common.health import health_check from server.data_common.matrix_loader import MatrixDataLoader from functools import wraps webbp = Blueprint("webapp", "server.common.web", template_folder="templates") ONE_WEEK = 7 * 24 * 60 * 60 def _cache_control(always, **cache_kwargs): """ Used to easily manage cache control headers on responses. See Werkzeug for attributes that can be set, eg, no_cache, private, max_age, etc. https://werkzeug.palletsprojects.com/en/1.0.x/datastructures/#werkzeug.datastructures.ResponseCacheControl """ def inner_cache_control(f): @wraps(f) def wrapper(*args, **kwargs): response = make_response(f(*args, **kwargs)) if not always and not current_app.app_config.server_config.app__generate_cache_control_headers: return response if response.status_code >= 400: return response for k, v in cache_kwargs.items(): setattr(response.cache_control, k, v) return response return wrapper return inner_cache_control def cache_control(**cache_kwargs): """ config driven """ return _cache_control(False, **cache_kwargs) def cache_control_always(**cache_kwargs): """ always generate headers, regardless of the config """ return _cache_control(True, **cache_kwargs) # tell the client not to cache the index.html page so that changes to the app work on redeployment # note that the bulk of the data needed by the client (datasets) will still be cached @webbp.route("/", methods=["GET"]) @cache_control_always(public=True, max_age=0, no_store=True, no_cache=True, must_revalidate=True) def dataset_index(url_dataroot=None, dataset=None): app_config = current_app.app_config server_config = app_config.server_config if dataset is None: if app_config.is_multi_dataset(): return dataroot_index() else: location = server_config.single_dataset__datapath else: dataroot = None for key, dataroot_dict in server_config.multi_dataset__dataroot.items(): if dataroot_dict["base_url"] == url_dataroot: dataroot = dataroot_dict["dataroot"] break if dataroot is None: abort(HTTPStatus.NOT_FOUND) location = path_join(dataroot, dataset) dataset_config = app_config.get_dataset_config(url_dataroot) scripts = dataset_config.app__scripts inline_scripts = dataset_config.app__inline_scripts try: cache_manager = current_app.matrix_data_cache_manager with cache_manager.data_adaptor(url_dataroot, location, app_config) as data_adaptor: data_adaptor.set_uri_path(f"{url_dataroot}/{dataset}") dataset_title = app_config.get_title(data_adaptor) return render_template( "index.html", datasetTitle=dataset_title, SCRIPTS=scripts, INLINE_SCRIPTS=inline_scripts ) except DatasetAccessError as e: return common_rest.abort_and_log( e.status_code, f"Invalid dataset {dataset}: {e.message}", loglevel=logging.INFO, include_exc_info=True ) @webbp.route("/health", methods=["GET"]) @cache_control_always(no_store=True) def health(): config = current_app.app_config return health_check(config) @webbp.errorhandler(RequestException) def handle_request_exception(error): return common_rest.abort_and_log(error.status_code, error.message, loglevel=logging.INFO, include_exc_info=True) def get_data_adaptor(url_dataroot=None, dataset=None): config = current_app.app_config server_config = config.server_config dataset_key = None if dataset is None: datapath = server_config.single_dataset__datapath else: dataroot = None for key, dataroot_dict in server_config.multi_dataset__dataroot.items(): if dataroot_dict["base_url"] == url_dataroot: dataroot = dataroot_dict["dataroot"] dataset_key = key break if dataroot is None: raise DatasetAccessError(f"Invalid dataset {url_dataroot}/{dataset}") datapath = path_join(dataroot, dataset) # path_join returns a normalized path. Therefore it is # sufficient to check that the datapath starts with the # dataroot to determine that the datapath is under the dataroot. if not datapath.startswith(dataroot): raise DatasetAccessError(f"Invalid dataset {url_dataroot}/{dataset}") if datapath is None: return common_rest.abort_and_log(HTTPStatus.BAD_REQUEST, "Invalid dataset NONE", loglevel=logging.INFO) cache_manager = current_app.matrix_data_cache_manager return cache_manager.data_adaptor(dataset_key, datapath, config) def requires_authentication(func): @wraps(func) def wrapped_function(self, *args, **kwargs): auth = current_app.auth if auth.is_user_authenticated(): return func(self, *args, **kwargs) else: return make_response("not authenticated", HTTPStatus.UNAUTHORIZED) return wrapped_function def rest_get_data_adaptor(func): @wraps(func) def wrapped_function(self, dataset=None): try: with get_data_adaptor(self.url_dataroot, dataset) as data_adaptor: data_adaptor.set_uri_path(f"{self.url_dataroot}/{dataset}") return func(self, data_adaptor) except DatasetAccessError as e: return common_rest.abort_and_log( e.status_code, f"Invalid dataset {dataset}: {e.message}", loglevel=logging.INFO, include_exc_info=True ) return wrapped_function def dataroot_test_index(): # the following index page is meant for testing/debugging purposes data = '' data += "
Logged in as {auth.get_user_id()} / {auth.get_user_name()} / {auth.get_user_email()}
" if auth.requires_client_login(): if server_config.auth.is_user_authenticated(): data += "" else: data += "" datasets = [] for dataroot_dict in server_config.multi_dataset__dataroot.values(): dataroot = dataroot_dict["dataroot"] url_dataroot = dataroot_dict["base_url"] locator = DataLocator(dataroot, region_name=server_config.data_locator__s3__region_name) for fname in locator.ls(): location = path_join(dataroot, fname) try: MatrixDataLoader(location, app_config=config) datasets.append((url_dataroot, fname)) except DatasetAccessError: # skip over invalid datasets pass data += "