def get_document(tenant, template, format): """Return report with specified template and format. :param str template: Template ID :param str format: Document format """ config = config_handler.tenant_config(tenant) jasper_service_url = config.get('jasper_service_url', 'http://localhost:8002/reports') jasper_timeout = config.get("jasper_timeout", 60) resources = config.resources().get('document_templates', []) permissions_handler = PermissionsReader(tenant, app.logger) permitted_resources = permissions_handler.resource_permissions( 'document_templates', get_auth_user()) if template in permitted_resources: resource = list( filter(lambda entry: entry.get("template") == template, resources)) if len(resource) != 1: app.logger.info("Template '%s' not found in config", template) abort(404) jasper_template = resource[0]['report_filename'] # http://localhost:8002/reports/BelasteteStandorte/?format=pdf&p1=v1&.. url = "%s/%s/" % (jasper_service_url, jasper_template) params = {"format": format} for k, v in request.args.lists(): params[k] = v app.logger.info("Forward request to %s?%s" % (url, urlencode(params))) response = requests.get(url, params=params, timeout=jasper_timeout) r = Response(stream_with_context( response.iter_content(chunk_size=16 * 1024)), content_type=response.headers['content-type'], status=response.status_code) return r else: app.logger.info("Missing permissions for template '%s'", template) abort(404)
def __get_link(self, identity, program, path, args): tenant = tenant_handler.tenant() permissions_handler = PermissionsReader(tenant, app.logger) permitted_resources = permissions_handler.resource_permissions( 'external_links', identity, program) if not permitted_resources: app.logger.warning( "Identity %s is not allowed to open link for program %s" % (identity, program)) api.abort(404, 'Unable to open link') config_handler = RuntimeConfig("ext", app.logger) config = config_handler.tenant_config(tenant_handler.tenant()) program_map = config.resources().get("external_links", []) link = None for entry in program_map: if entry["name"] == program: link = entry["url"] break if not link: app.logger.warning("No link configured for program %s" % (program)) api.abort(404, 'Unable to open link') parts = parse.urlsplit(link) query = dict(parse.parse_qsl(parts.query)) for key in query: query[key] = query[key].replace('$tenant$', tenant) query.update(args) parts = parts._replace(query=parse.urlencode(query)) if path: parts = parts._replace(path=os.path.dirname(parts.path) + "/" + path) link = parts.geturl() api.logger.info("Proxying " + link) return link
class LegendService: """LegendService class Provide legend graphics for WMS layers with custom legend images. Acts as a proxy to a QGIS server. """ def __init__(self, tenant, logger): """Constructor :param Logger logger: Application logger """ self.tenant = tenant self.logger = logger config_handler = RuntimeConfig("legend", logger) config = config_handler.tenant_config(tenant) # get internal QGIS server URL from config self.qgis_server_url = config.get( 'default_qgis_server_url', 'http://localhost:8001/ows/').rstrip('/') + '/' # get path to legend images from config self.legend_images_path = config.get('legend_images_path', 'legends/') # temporary target dir for any Base64 encoded legend images # NOTE: this dir will be cleaned up automatically on reload self.images_temp_dir = None self.resources = self.load_resources(config) self.permissions_handler = PermissionsReader(tenant, logger) def get_legend(self, service_name, layer_param, format_param, params, type, identity): """Return legend graphic for specified layer. :param str service_name: Service name :param str layer_param: WMS layer names :param str format_param: Image format :param dict params: Other params to forward to QGIS Server :param str type: The legend image type, either "default", "thumbnail" or "tooltip". :param obj identity: User identity """ if not self.wms_permitted(service_name, identity): # map unknown or not permitted return self.service_exception( 'MapNotDefined', 'Map "%s" does not exist or is not permitted' % service_name) if format_param not in PIL_Formats: self.logger.warning( "Unsupported format requested, falling back to image/png") format_param = "image/png" # get permitted resources requested_layers = layer_param.split(',') permitted_resources = self.permitted_resources(service_name, identity) permitted_layers = permitted_resources['permitted_layers'] public_layers = permitted_resources['public_layers'] group_layers = permitted_resources['groups_to_expand'] # filter layers by permissions requested_layers = [ layer for layer in requested_layers if layer in public_layers ] # replace group layers containing custom legends with permitted # sublayers expanded_layers = self.expand_group_layers(requested_layers, group_layers, permitted_layers) self.logger.debug("Requested layers: %s" % requested_layers) self.logger.debug("Expanded layers: %s" % expanded_layers) dpi = params.get('dpi') imgdata = [] for layer in expanded_layers: legend_image = self.get_legend_image(service_name, layer, type) if legend_image is not None: if dpi and dpi != '90': try: # scale image to requested DPI img = Image.open(BytesIO(legend_image)) scale = float(dpi) / 90.0 new_size = (int(img.width * scale), int(img.height * scale)) img = img.resize(new_size, Image.ANTIALIAS) output = BytesIO() # NOTE: save as PNG to preserve any alpha channel img.save(output, "PNG") imgdata.append({"data": output, "format": None}) except Exception as e: self.logger.error( "Could not resize image for %s:\n%s" % (layer, e)) imgdata.append({ "data": BytesIO(legend_image), "format": None }) else: imgdata.append({ "data": BytesIO(legend_image), "format": None }) else: req_params = { "service": "WMS", "version": "1.3.0", "request": "GetLegendGraphic", "layer": layer, "format": format_param, "style": "" } req_params.update(params) response = requests.get(self.qgis_server_url + service_name, params=req_params, timeout=10) self.logger.debug("Forwarding request to %s" % response.url) if response.content.startswith(b'<ServiceExceptionReport'): self.logger.warning(response.content) elif response.status_code == 200: buf = BytesIO() buf.write(response.content) imgdata.append({"data": buf, "format": format_param}) else: # Empty image in case of server error output = BytesIO() Image.new("RGB", (1, 1), (255, 255, 255)).save(output, PIL_Formats[format_param]) imgdata.append({"data": output, "format": format_param}) if len(imgdata) == 0: # layer not found or faulty return self.service_exception( 'LayerNotDefined', 'Layer "%s" does not exist or is not permitted' % layer_param) # If just one image, return it elif len(imgdata) == 1: # Convert to requested format if necessary if imgdata[0]["format"] != format_param: output = BytesIO() try: imgdata[0]["data"].seek(0) image = Image.open(imgdata[0]["data"]) if not self.format_has_alpha(format_param): image = self.convert_img_to_rgb(image) image.save(output, PIL_Formats[format_param]) except Exception as e: self.logger.error("Could not convert image to %s:\n%s" % (format_param, e)) # Empty 1x1 image Image.new("RGB", (1, 1), (255, 255, 255)).save(output, PIL_Formats[format_param]) output.seek(0) imgdata[0]["data"] = output imgdata[0]["data"].seek(0) return send_file(imgdata[0]["data"], mimetype=format_param) # Otherwise, compose images width = 0 height = 0 for entry in imgdata: try: entry["image"] = Image.open(entry["data"]) if not self.format_has_alpha(format_param): entry["image"] = self.convert_img_to_rgb(entry["image"]) width = max(width, entry["image"].width) height += entry["image"].height except: entry["image"] = None if self.format_has_alpha(format_param): image = Image.new("RGBA", (width, height), (255, 255, 255, 255)) else: image = Image.new("RGB", (width, height), (255, 255, 255)) y = 0 for entry in imgdata: if entry["image"]: image.paste(entry["image"], (0, y)) y += entry["image"].height data = BytesIO() image.save(data, PIL_Formats[format_param]) data.seek(0) return send_file(data, mimetype=format_param) def service_exception(self, code, message): """Create ServiceExceptionReport XML response :param str code: ServiceException code :param str message: ServiceException text """ return Response(('<ServiceExceptionReport version="1.3.0">\n' ' <ServiceException code="%s">%s</ServiceException>\n' '</ServiceExceptionReport>' % (code, message)), content_type='text/xml; charset=utf-8', status=200) def expand_group_layers(self, requested_layers, groups_to_expand, permitted_layers): """Recursively filter layers by permissions and replace group layers with permitted sublayers and return resulting layer list. :param list(str) requested_layers: List of requested layer names :param obj groups_to_expand: Lookup for group layers with sublayers that have custom legends or are restricted :param list(str) permitted_layers: List of permitted layer names """ expanded_layers = [] for layer in requested_layers: if layer in permitted_layers: if layer in groups_to_expand: # expand sublayers sublayers = [] for sublayer in groups_to_expand.get(layer): if sublayer in permitted_layers: sublayers.append(sublayer) expanded_layers += self.expand_group_layers( sublayers, groups_to_expand, permitted_layers) else: # leaf layer or full group layer expanded_layers.append(layer) return expanded_layers def get_legend_image(self, service_name, layer, type): """Return any custom legend image for a layer. :param str service_name: Service name :param str layer: WMS Layer name :param str type: Legend image type (default|thumbnail|tooltip) """ image_data = None # attempt to match legend image by filename filenames = [] allowempty = False if type == "thumbnail": filenames.append(layer + "_thumbnail.png") elif type == "tooltip": allowempty = True filenames.append(layer + '.png') for filename in filenames: try: data = open( os.path.join(self.legend_images_path, service_name, filename), 'rb').read() if data or allowempty: return data except: pass # get lookup for custom legend images wms_resources = self.resources['wms_services'][service_name] legend_images = wms_resources['legend_images'] if layer not in legend_images: # layer has no custom legend image return None # TODO: legend image types try: # NOTE: uses absolute path for extracted Base64 encoded images image_path = os.path.join(self.legend_images_path, legend_images[layer]) if os.path.isfile(image_path): self.logger.debug("Loading legend image '%s' for layer '%s'" % (image_path, layer)) # load image file with open(image_path, 'rb') as f: image_data = f.read() else: self.logger.warning( "Could not find legend image '%s' for layer '%s'" % (image_path, layer)) except Exception as e: self.logger.error( "Could not load legend image '%s' for layer '%s':\n%s" % (image_path, layer, e)) return image_data def format_has_alpha(self, format_param): """Return whether image format supports alpha channel. :param string format_param: Image format as media type """ return format_param in FORMATS_WITH_ALPHA def convert_img_to_rgb(self, image): """Return image as RGB, converting from RGBA if necessary. :param Image image: Input image """ if image.mode == 'RGBA': # remove alpha channel by compositing with white background background = Image.new("RGBA", image.size, (255, 255, 255, 255)) image = Image.alpha_composite(background, image).convert("RGB") return image def load_resources(self, config): """Load service resources from config. :param RuntimeConfig config: Config handler """ wms_services = {} # collect service resources for wms in config.resources().get('wms_services', []): # collect WMS layers resources = { # root layer name 'root_layer': wms['root_layer']['name'], # public layers without hidden sublayers: [<layers>] 'public_layers': [], # available layers including hidden sublayers: [<layers>] 'available_layers': [], # lookup for complete group layers # sub layers ordered from top to bottom: # {<group>: [<sub layers]} 'group_layers': {}, # lookup for group layers containing layers with # custom legend images # sub layers ordered from top to bottom: # {<group>: [<sub layers]} 'groups_to_expand': {}, # lookup for layers with custom legend images: # {<layer>: <legend img>} 'legend_images': {} } self.collect_layers(wms['root_layer'], resources, False) wms_services[wms['name']] = resources return {'wms_services': wms_services} def collect_layers(self, layer, resources, hidden): """Recursively collect layer info for layer subtree from config. :param obj layer: Layer or group layer :param obj resources: Partial lookups for layer resources :param bool hidden: Whether layer is a hidden sublayer """ if not hidden: resources['public_layers'].append(layer['name']) resources['available_layers'].append(layer['name']) if layer.get('layers'): # group layer hidden |= layer.get('hide_sublayers', False) sublayers_have_custom_legend = False # collect sublayers sublayers = [] for sublayer in layer['layers']: sublayers.append(sublayer['name']) # recursively collect sublayer self.collect_layers(sublayer, resources, hidden) if (sublayer['name'] in resources['legend_images'] or sublayer['name'] in resources['groups_to_expand']): # sublayer has custom legend image # or is a group containing such sublayers sublayers_have_custom_legend |= True resources['group_layers'][layer['name']] = sublayers if layer.get('hide_sublayers') and ( layer.get('legend_image') or layer.get('legend_image_base64')): # set custom legend image for group with hidden sublayers # Note: overrides any custom legend image of sublayers image_path = self.legend_image_path(layer) if image_path is not None: resources['legend_images'][layer['name']] = image_path elif sublayers_have_custom_legend: # group has sublayer with custom legend image resources['groups_to_expand'][layer['name']] = sublayers else: # layer if layer.get('legend_image') or layer.get('legend_image_base64'): # set custom legend image image_path = self.legend_image_path(layer) if image_path is not None: resources['legend_images'][layer['name']] = image_path def legend_image_path(self, layer): """Return path to custom legend image (either from file or from Base64 encoded image). :param obj layer: Layer or group layer """ image_path = None if layer.get('legend_image'): # relative path to legend_images_path image_path = layer.get('legend_image') elif layer.get('legend_image_base64'): # absolute path to images_temp_dir image_path = self.extract_base64_legend_image(layer) return image_path def extract_base64_legend_image(self, layer): """Extract Base64 encoded legend image to file and return its path. :param obj layer: Layer or group layer """ image_path = None try: if self.images_temp_dir is None: # create temporary target dir self.images_temp_dir = tempfile.TemporaryDirectory( prefix='qwc-legend-service-') # decode and save as image file filename = "%s-%s.png" % (layer['name'], uuid.uuid4()) image_path = os.path.join(self.images_temp_dir.name, filename) with open(image_path, 'wb') as f: f.write(base64.b64decode(layer.get('legend_image_base64'))) except Exception as e: image_path = None self.logger.error( "Could not extract Base64 encoded legend image for layer '%s':" "\n%s" % (layer['name'], e)) return image_path def wms_permitted(self, service_name, identity): """Return whether WMS is available and permitted. :param str service_name: Service name :param obj identity: User identity """ if self.resources['wms_services'].get(service_name): # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, service_name) if wms_permissions: return True return False def permitted_resources(self, service_name, identity): """Return permitted resources for a legend service. :param str service_name: Service name :param obj identity: User identity """ if not self.resources['wms_services'].get(service_name): # WMS service unknown return {} # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, service_name) if not wms_permissions: # WMS not permitted return {} wms_resources = self.resources['wms_services'][service_name].copy() # get available layers available_layers = wms_resources['available_layers'] # combine permissions permitted_layers = set() for permission in wms_permissions: for layer in permission['layers']: name = layer['name'] if name in available_layers: permitted_layers.add(name) # filter by permissions # public layers public_layers = [ layer for layer in wms_resources['public_layers'] if layer in permitted_layers ] # collect restricted group layers restricted_group_layers = {} self.collect_restricted_group_layers(wms_resources['root_layer'], wms_resources['group_layers'], permitted_layers, restricted_group_layers) # merge with groups to expand groups_to_expand = wms_resources['groups_to_expand'] for group, allowed_sublayers in restricted_group_layers.items(): # update with allowed layers groups_to_expand[group] = allowed_sublayers return { 'permitted_layers': sorted(list(permitted_layers)), 'public_layers': public_layers, 'groups_to_expand': groups_to_expand } def collect_restricted_group_layers(self, layer, group_layers, permitted_layers, restricted_group_layers): """Recursively collect group layers with restricted sublayers. :param str layer: Layer name :param obj group_layers: Lookup for group layers :param list(str) permitted_layers: List of permitted layer names :Param obj restricted_group_layers: Partial lookup for restricted group layers """ if layer in group_layers: # group layer # collect sublayers sublayers = [] sublayers_restricted = False for sublayer in group_layers[layer]: if sublayer in permitted_layers: # add permitted layer sublayers.append(sublayer) # recursively collect sublayer self.collect_restricted_group_layers(sublayer, group_layers, permitted_layers, restricted_group_layers) if (sublayer not in permitted_layers or sublayer in restricted_group_layers): # sublayer is restricted # or is a group containing such sublayers sublayers_restricted |= True if sublayers_restricted: # group has restricted sublayers restricted_group_layers[layer] = sublayers
class DataService(): """DataService class Manage reading and writing of dataset features. """ def __init__(self, tenant, logger): """Constructor :param str tenant: Tenant ID :param Logger logger: Application logger """ self.tenant = tenant self.logger = logger self.resources = self.load_resources() self.permissions_handler = PermissionsReader(tenant, logger) self.db_engine = DatabaseEngine() def index(self, identity, dataset, bbox, crs, filterexpr): """Find dataset features inside bounding box. :param str identity: User identity :param str dataset: Dataset ID :param str bbox: Bounding box as '<minx>,<miny>,<maxx>,<maxy>' or None :param str crs: Client CRS as 'EPSG:<srid>' or None :param str filterexpr: JSON serialized array of filter expressions: [["<attr>", "<op>", "<value>"], "and|or", ["<attr>", "<op>", "<value>"]] """ dataset_features_provider = self.dataset_features_provider( identity, dataset ) if dataset_features_provider is not None: # check read permission if not dataset_features_provider.readable(): return { 'error': "Dataset not readable", 'error_code': 405 } if bbox is not None: # parse and validate input bbox bbox = dataset_features_provider.parse_bbox(bbox) if bbox is None: return { 'error': "Invalid bounding box", 'error_code': 400 } srid = None if crs is not None: # parse and validate unput CRS srid = dataset_features_provider.parse_crs(crs) if srid is None: return { 'error': "Invalid CRS", 'error_code': 400 } if filterexpr is not None: # parse and validate input filter filterexpr = dataset_features_provider.parse_filter(filterexpr) if filterexpr[0] is None: return { 'error': ( "Invalid filter expression: %s" % filterexpr[1] ), 'error_code': 400 } try: feature_collection = dataset_features_provider.index( bbox, srid, filterexpr ) except (DataError, ProgrammingError) as e: self.logger.error(e) return { 'error': ( "Feature query failed. Please check filter expression " "values and operators." ), 'error_code': 400 } return {'feature_collection': feature_collection} else: return {'error': "Dataset not found or permission error"} def show(self, identity, dataset, id, crs): """Get a dataset feature. :param str identity: User identity :param str dataset: Dataset ID :param int id: Dataset feature ID :param str crs: Client CRS as 'EPSG:<srid>' or None """ dataset_features_provider = self.dataset_features_provider( identity, dataset ) srid = None if crs is not None: # parse and validate unput CRS srid = dataset_features_provider.parse_crs(crs) if srid is None: return { 'error': "Invalid CRS", 'error_code': 400 } if dataset_features_provider is not None: # check read permission if not dataset_features_provider.readable(): return { 'error': "Dataset not readable", 'error_code': 405 } feature = dataset_features_provider.show(id, srid) if feature is not None: return {'feature': feature} else: return {'error': "Feature not found"} else: return {'error': "Dataset not found or permission error"} def create(self, identity, dataset, feature): """Create a new dataset feature. :param str identity: User identity :param str dataset: Dataset ID :param object feature: GeoJSON Feature """ dataset_features_provider = self.dataset_features_provider( identity, dataset ) if dataset_features_provider is not None: # check create permission if not dataset_features_provider.creatable(): return { 'error': "Dataset not creatable", 'error_code': 405 } # validate input feature validation_errors = dataset_features_provider.validate( feature, new_feature=True ) if not validation_errors: # create new feature try: feature = dataset_features_provider.create(feature) except (DataError, InternalError, ProgrammingError) as e: self.logger.error(e) return { 'error': "Feature commit failed", 'error_details': { 'data_errors': ["Feature could not be created"], }, 'error_code': 422 } return {'feature': feature} else: return { 'error': "Feature validation failed", 'error_details': validation_errors, 'error_code': 422 } else: return {'error': "Dataset not found or permission error"} def update(self, identity, dataset, id, feature): """Update a dataset feature. :param str identity: User identity :param str dataset: Dataset ID :param int id: Dataset feature ID :param object feature: GeoJSON Feature """ dataset_features_provider = self.dataset_features_provider( identity, dataset ) if dataset_features_provider is not None: # check update permission if not dataset_features_provider.updatable(): return { 'error': "Dataset not updatable", 'error_code': 405 } # validate input feature validation_errors = dataset_features_provider.validate(feature) if not validation_errors: # update feature try: feature = dataset_features_provider.update(id, feature) except (DataError, InternalError, ProgrammingError) as e: self.logger.error(e) return { 'error': "Feature commit failed", 'error_details': { 'data_errors': ["Feature could not be updated"], }, 'error_code': 422 } if feature is not None: return {'feature': feature} else: return {'error': "Feature not found"} else: return { 'error': "Feature validation failed", 'error_details': validation_errors, 'error_code': 422 } else: return {'error': "Dataset not found or permission error"} def destroy(self, identity, dataset, id): """Delete a dataset feature. :param str identity: User identity :param str dataset: Dataset ID :param int id: Dataset feature ID """ dataset_features_provider = self.dataset_features_provider( identity, dataset ) if dataset_features_provider is not None: # check delete permission if not dataset_features_provider.deletable(): return { 'error': "Dataset not deletable", 'error_code': 405 } if dataset_features_provider.destroy(id): return {} else: return {'error': "Feature not found"} else: return {'error': "Dataset not found or permission error"} def is_editable(self, identity, dataset, id): """Returns whether a dataset is editable. :param str identity: User identity :param str dataset: Dataset ID :param int id: Dataset feature ID """ dataset_features_provider = self.dataset_features_provider( identity, dataset ) if dataset_features_provider is not None: # check update permission if not dataset_features_provider.updatable(): return False return dataset_features_provider.exists(id) def dataset_features_provider(self, identity, dataset): """Return DatasetFeaturesProvider if available and permitted. :param str identity: User identity :param str dataset: Dataset ID """ dataset_features_provider = None # check permissions permissions = self.dataset_edit_permissions( dataset, identity ) if permissions: # create DatasetFeaturesProvider dataset_features_provider = DatasetFeaturesProvider( permissions, self.db_engine ) return dataset_features_provider def load_resources(self): """Load service resources from config.""" # read config config_handler = RuntimeConfig("data", self.logger) config = config_handler.tenant_config(self.tenant) # get service resources datasets = {} for resource in config.resources().get('datasets', []): datasets[resource['name']] = resource return { 'datasets': datasets } def dataset_edit_permissions(self, dataset, identity): """Return dataset edit permissions if available and permitted. :param str dataset: Dataset ID :param obj identity: User identity """ # find resource for requested dataset resource = self.resources['datasets'].get(dataset) if resource is None: # dataset not found return {} # get permissions for dataset resource_permissions = self.permissions_handler.resource_permissions( 'data_datasets', identity, dataset ) if not resource_permissions: # dataset not permitted return {} # combine permissions permitted_attributes = set() writable = False creatable = False readable = False updatable = False deletable = False for permission in resource_permissions: # collect permitted attributes permitted_attributes.update(permission.get('attributes', [])) # allow writable and CRUD actions if any role permits them writable |= permission.get('writable', False) creatable |= permission.get('creatable', False) readable |= permission.get('readable', False) updatable |= permission.get('updatable', False) deletable |= permission.get('deletable', False) # make writable consistent with CRUD actions writable |= creatable and readable and updatable and deletable # make CRUD actions consistent with writable creatable |= writable readable |= writable updatable |= writable deletable |= writable permitted = creatable or readable or updatable or deletable if not permitted: # no CRUD action permitted return {} # filter by permissions attributes = [ field['name'] for field in resource['fields'] if field['name'] in permitted_attributes ] fields = {} for field in resource['fields']: if field['name'] in permitted_attributes: fields[field['name']] = field # NOTE: 'geometry' is None for datasets without geometry geometry = resource.get('geometry', {}) return { "dataset": resource['name'], "database": resource['db_url'], "schema": resource['schema'], "table_name": resource['table_name'], "primary_key": resource['primary_key'], "attributes": attributes, "fields": fields, "geometry_column": geometry.get('geometry_column'), "geometry_type": geometry.get('geometry_type'), "srid": geometry.get('srid'), "allow_null_geometry": geometry.get('allow_null', False), "writable": writable, "creatable": creatable, "readable": readable, "updatable": updatable, "deletable": deletable }
class OGCService: """OGCService class Provide OGC services (WMS, WFS) with permission filters. Acts as a proxy to a QGIS server. """ def __init__(self, tenant, logger): """Constructor :param str tenant: Tenant ID :param Logger logger: Application logger """ self.tenant = tenant self.logger = logger config_handler = RuntimeConfig("ogc", logger) config = config_handler.tenant_config(tenant) # get internal QGIS server URL from config # (default: local qgis-server container) self.default_qgis_server_url = config.get( 'default_qgis_server_url', 'http://localhost:8001/ows/').rstrip('/') + '/' self.public_ogc_url_pattern = config.get('public_ogc_url_pattern', '$origin$/.*/?$mountpoint$') self.resources = self.load_resources(config) self.permissions_handler = PermissionsReader(tenant, logger) def get(self, identity, service_name, host_url, params, script_root, origin): """Check and filter OGC GET request and forward to QGIS server. :param str identity: User identity :param str service_name: OGC service name :param str host_url: host url :param obj params: Request parameters :param str script_root: Request root path :param str origin: The origin of the original request """ return self.request(identity, 'GET', service_name, host_url, params, script_root, origin) def post(self, identity, service_name, host_url, params, script_root, origin): """Check and filter OGC POST request and forward to QGIS server. :param str identity: User identity :param str service_name: OGC service name :param str host_url: host url :param obj params: Request parameters :param str script_root: Request root path :param str origin: The origin of the original request """ return self.request(identity, 'POST', service_name, host_url, params, script_root, origin) def request(self, identity, method, service_name, host_url, params, script_root, origin): """Check and filter OGC request and forward to QGIS server. :param str identity: User identity :param str method: Request method 'GET' or 'POST' :param str service_name: OGC service name :param str host_url: host url :param obj params: Request parameters :param str script_root: Request root path :param str origin: The origin of the original request """ # normalize parameter keys to upper case params = {k.upper(): v for k, v in params.items()} # get permission permission = self.service_permissions(identity, service_name, params.get('SERVICE')) # check request exception = self.check_request(params, permission) if exception: return Response(self.service_exception(exception['code'], exception['message']), content_type='text/xml; charset=utf-8', status=200) # adjust request parameters self.adjust_params(params, permission, origin) # forward request and return filtered response return self.forward_request(method, host_url, params, script_root, permission) def check_request(self, params, permission): """Check request parameters and permissions. :param obj params: Request parameters :param obj permission: OGC service permission """ exception = {} if permission.get('service_name') is None: # service unknown or not permitted exception = { 'code': "Service configuration error", 'message': "Service unknown or unsupported" } elif not params.get('REQUEST'): # REQUEST missing or blank exception = { 'code': "OperationNotSupported", 'message': "Please check the value of the REQUEST parameter" } else: service = params.get('SERVICE', '') request = params.get('REQUEST', '').upper() if service == 'WMS' and request == 'GETFEATUREINFO': # check info format info_format = params.get('INFO_FORMAT', 'text/plain') if re.match('^application/vnd.ogc.gml.+$', info_format): # do not support broken GML3 info format # i.e. 'application/vnd.ogc.gml/3.1.1' exception = { 'code': "InvalidFormat", 'message': ("Feature info format '%s' is not supported. " "Possibilities are 'text/plain', 'text/html' or " "'text/xml'." % info_format) } elif service == 'WMS' and request == 'GETPRINT': # check print templates template = params.get('TEMPLATE') if template and template not in permission['print_templates']: # allow only permitted print templates exception = { 'code': "Error", 'message': ('Composer template not found or not permitted') } elif service == 'WFS' and request == 'TRANSACTION': # WFS-T not supported exception = { 'code': "OperationNotSupported", 'message': "WFS Transaction is not supported" } if not exception: # check layers params # lookup for layers params by request # { # <SERVICE>: { # <REQUEST>: [ # <optional layers param>, <mandatory layers param> # ] # } # } ogc_layers_params = { 'WMS': { 'GETMAP': ['LAYERS', None], 'GETFEATUREINFO': ['LAYERS', 'QUERY_LAYERS'], 'GETLEGENDGRAPHIC': [None, 'LAYER'], 'GETLEGENDGRAPHICS': [None, 'LAYER'], # QGIS legacy request 'DESCRIBELAYER': [None, 'LAYERS'], 'GETSTYLES': [None, 'LAYERS'] }, 'WFS': { 'DESCRIBEFEATURETYPE': ['TYPENAME', None], 'GETFEATURE': [None, 'TYPENAME'] } } layer_params = ogc_layers_params.get(service, {}).get(request, {}) if service == 'WMS' and request == 'GETPRINT': mapname = self.get_map_param_prefix(params) if mapname and (mapname + ":LAYERS") in params: layer_params = [mapname + ":LAYERS", None] if layer_params: permitted_layers = permission['public_layers'].copy() filename = params.get('FILENAME', '') if (service == 'WMS' and ((request == 'GETMAP' and filename) or request == 'GETPRINT')): # When doing a raster export (GetMap with FILENAME) # or printing (GetPrint), also allow background or external layers permitted_layers += permission['internal_print_layers'] if layer_params[0] is not None: # check optional layers param exception = self.check_layers(layer_params[0], params, permitted_layers, False) if not exception and layer_params[1] is not None: # check mandatory layers param exception = self.check_layers(layer_params[1], params, permitted_layers, True) return exception def check_layers(self, layer_param, params, permitted_layers, mandatory): """Check presence and permitted layers for requested layers parameter. :param str layer_param: Name of layers parameter :param obj params: Request parameters :param list(str) permitted_layers: List of permitted layer names :param bool mandatory: Layers parameter is mandatory """ exception = None wms_layer_pattern = re.compile("^wms:(.+)#(.+)$") wfs_layer_pattern = re.compile("^wfs:(.+)#(.+)$") requested_layers = params.get(layer_param) if requested_layers: requested_layers = requested_layers.split(',') for layer in requested_layers: # allow only permitted layers if (layer and not wms_layer_pattern.match(layer) and not wfs_layer_pattern.match(layer) and not layer.startswith('EXTERNAL_WMS:') and layer not in permitted_layers): exception = { 'code': "LayerNotDefined", 'message': ('Layer "%s" does not exist or is not permitted' % layer) } break elif mandatory: # mandatory layers param is missing or blank exception = { 'code': "MissingParameterValue", 'message': ('%s is mandatory for %s operation' % (layer_param, params.get('REQUEST'))) } return exception def service_exception(self, code, message): """Create ServiceExceptionReport XML :param str code: ServiceException code :param str message: ServiceException text """ return ('<ServiceExceptionReport version="1.3.0">\n' ' <ServiceException code="%s">%s</ServiceException>\n' '</ServiceExceptionReport>' % (code, message)) def adjust_params(self, params, permission, origin): """Adjust parameters depending on request and permissions. :param obj params: Request parameters :param obj permission: OGC service permission :param str origin: The origin of the original request """ ogc_service = params.get('SERVICE', '') ogc_request = params.get('REQUEST', '').upper() if ogc_service == 'WFS': # always use version 1.0.0 for WFS requests self.logger.warning("Overriding WFS VERSION=1.0.0") params['VERSION'] = '1.0.0' if ogc_service == 'WMS' and ogc_request == 'GETMAP': requested_layers = params.get('LAYERS') if requested_layers: # collect requested layers and opacities requested_layers = requested_layers.split(',') requested_layers_opacities = self.padded_opacities( requested_layers, params.get('OPACITIES')) # replace restricted group layers with permitted sublayers restricted_group_layers = permission['restricted_group_layers'] hidden_sublayer_opacities = permission[ 'hidden_sublayer_opacities'] permitted_layers_opacities = \ self.expand_group_layers_and_opacities( requested_layers_opacities, restricted_group_layers, hidden_sublayer_opacities ) permitted_layers = [ l['layer'] for l in permitted_layers_opacities ] permitted_opacities = [ l['opacity'] for l in permitted_layers_opacities ] params['LAYERS'] = ",".join(permitted_layers) params['OPACITIES'] = ",".join( [str(o) for o in permitted_opacities]) elif ogc_service == 'WMS' and ogc_request == 'GETFEATUREINFO': requested_layers = params.get('QUERY_LAYERS') if requested_layers: # replace restricted group layers with permitted sublayers requested_layers = requested_layers.split(',') restricted_group_layers = permission['restricted_group_layers'] permitted_layers = self.expand_group_layers( reversed(requested_layers), restricted_group_layers) # filter by queryable layers queryable_layers = permission['queryable_layers'] permitted_layers = [ l for l in permitted_layers if l in queryable_layers ] # reverse layer order permitted_layers = reversed(permitted_layers) params['QUERY_LAYERS'] = ",".join(permitted_layers) elif (ogc_service == 'WMS' and ogc_request in ['GETLEGENDGRAPHIC', 'GETLEGENDGRAPHICS']): requested_layers = params.get('LAYER') if requested_layers: # replace restricted group layers with permitted sublayers requested_layers = requested_layers.split(',') restricted_group_layers = permission['restricted_group_layers'] permitted_layers = self.expand_group_layers( requested_layers, restricted_group_layers) params['LAYER'] = ",".join(permitted_layers) elif ogc_service == 'WMS' and ogc_request == 'GETPRINT': mapname = self.get_map_param_prefix(params) if mapname and (mapname + ":LAYERS") in params: requested_layers = params.get(mapname + ":LAYERS") if requested_layers: # collect requested layers and opacities requested_layers = requested_layers.split(',') requested_layers_opacities = self.padded_opacities( requested_layers, params.get('OPACITIES')) # replace restricted group layers with permitted sublayers restricted_group_layers = permission['restricted_group_layers'] hidden_sublayer_opacities = permission[ 'hidden_sublayer_opacities'] permitted_layers_opacities = \ self.expand_group_layers_and_opacities( requested_layers_opacities, restricted_group_layers, hidden_sublayer_opacities ) permitted_layers = [ l['layer'] for l in permitted_layers_opacities ] permitted_opacities = [ l['opacity'] for l in permitted_layers_opacities ] params[mapname + ":LAYERS"] = ",".join(permitted_layers) # NOTE: also set LAYERS, so QGIS Server applies OPACITIES # correctly params['LAYERS'] = params[mapname + ":LAYERS"] params['OPACITIES'] = ",".join( [str(o) for o in permitted_opacities]) # Rewrite URLs of EXTERNAL_WMS which point to the ogc service: # <...>?REQUEST=GetPrint&map0:LAYERS=EXTERNAL_WMS:A&A:URL=http://<ogc_service_url>/theme # And point the URLs directly to the qgis server. # This because: # - ogc_service_url may not be resolvable in the qgis server container # - Even if ogc_service_url were resolvable, qgis-server doesn't know about the identity of the logged in user, # hence it won't be able to load any restricted layers over the ogc service pattern = self.public_ogc_url_pattern\ .replace("$origin$", re.escape(origin.rstrip("/")))\ .replace("$tenant$", self.tenant)\ .replace("$mountpoint$", re.escape(os.getenv("SERVICE_MOUNTPOINT", "").lstrip("/").rstrip("/") + "/")) for layer in params[mapname + ":LAYERS"].split(","): if not layer.startswith("EXTERNAL_WMS:"): continue urlparam = layer[13:] + ":URL" if not urlparam in params: continue params[urlparam] = re.sub(pattern, self.default_qgis_server_url, params[urlparam]) elif ogc_service == 'WMS' and ogc_request == 'DESCRIBELAYER': requested_layers = params.get('LAYERS') if requested_layers: # replace restricted group layers with permitted sublayers requested_layers = requested_layers.split(',') restricted_group_layers = permission['restricted_group_layers'] permitted_layers = self.expand_group_layers( reversed(requested_layers), restricted_group_layers) # reverse layer order permitted_layers = reversed(permitted_layers) params['LAYERS'] = ",".join(permitted_layers) elif ogc_service == 'WFS' and ogc_request == 'GETFEATURE': requested_layers = params.get('TYPENAME') if requested_layers: requested_layers = requested_layers.split(',') if len(requested_layers) == 1: # single layer requested # get permitted attributes for layer permitted_attributes = permission['layers'].get( requested_layers[0], {}) propertyname = params.get('PROPERTYNAME') if propertyname: # filter requested attributes requested_attributes = propertyname.split(',') attributes = [ attr for attr in requested_attributes if attr in permitted_attributes ] params['PROPERTYNAME'] = ",".join(attributes) else: # add PROPERTYNAME to filter attributes in WFS server params['PROPERTYNAME'] = ",".join(permitted_attributes) def padded_opacities(self, requested_layers, opacities_param): """Complement requested opacities to match number of requested layers. :param list(str) requested_layers: List of requested layer names :param str opacities_param: Value of OPACITIES request parameter """ requested_layers_opacities = [] requested_opacities = [] if opacities_param: requested_opacities = opacities_param.split(',') for i, layer in enumerate(requested_layers): if i < len(requested_opacities): try: opacity = int(requested_opacities[i]) if opacity < 0 or opacity > 255: opacity = 255 except ValueError as e: opacity = 0 else: # pad missing opacities with 255 if i == 0 and opacities_param is not None: # empty OPACITIES param opacity = 0 else: opacity = 255 requested_layers_opacities.append({ 'layer': layer, 'opacity': opacity }) return requested_layers_opacities def expand_group_layers(self, requested_layers, restricted_group_layers): """Recursively replace group layers with permitted sublayers and return resulting layer list. :param list(str) requested_layers: List of requested layer names :param obj restricted_group_layers: Lookup for group layers with restricted sublayers """ permitted_layers = [] for layer in requested_layers: if layer in restricted_group_layers.keys(): # expand sublayers and reorder from bottom to top sublayers = reversed(restricted_group_layers.get(layer)) permitted_layers += self.expand_group_layers( sublayers, restricted_group_layers) else: # leaf layer or permitted group layer permitted_layers.append(layer) return permitted_layers def expand_group_layers_and_opacities(self, requested_layers_opacities, restricted_group_layers, hidden_sublayer_opacities): """Recursively replace group layers and opacities with permitted sublayers and return resulting layers and opacities list. :param list(obj) requested_layers_opacities: List of requested layer names and opacities as { 'layer': <layer name> 'opacity': <opacity> } :param obj restricted_group_layers: Lookup for group layers with restricted sublayers :param obj hidden_sublayer_opacities: Lookup for custom opacities of hidden sublayers """ permitted_layers_opacities = [] for lo in requested_layers_opacities: layer = lo['layer'] opacity = lo['opacity'] if layer in restricted_group_layers.keys(): # expand sublayers ordered from bottom to top, # use opacity from group sublayers = reversed(restricted_group_layers.get(layer)) sublayers_opacities = [] for sublayer in sublayers: sub_opacity = opacity if sublayer in hidden_sublayer_opacities: # scale opacity by custom opacity for hidden sublayer custom_opacity = hidden_sublayer_opacities.get( sublayer) sub_opacity = int(opacity * custom_opacity / 100) sublayers_opacities.append({ 'layer': sublayer, 'opacity': sub_opacity }) permitted_layers_opacities += \ self.expand_group_layers_and_opacities( sublayers_opacities, restricted_group_layers, hidden_sublayer_opacities ) else: # leaf layer or permitted group layer permitted_layers_opacities.append({ 'layer': layer, 'opacity': opacity }) return permitted_layers_opacities def forward_request(self, method, host_url, params, script_root, permission): """Forward request to QGIS server and return filtered response. :param str method: Request method 'GET' or 'POST' :param str host_url: host url :param obj params: Request parameters :param str script_root: Request root path :param obj permission: OGC service permission """ ogc_service = params.get('SERVICE', '') ogc_request = params.get('REQUEST', '').upper() stream = True if ogc_request in [ 'GETCAPABILITIES', 'GETPROJECTSETTINGS', 'GETFEATUREINFO', 'DESCRIBEFEATURETYPE' ]: # do not stream if response is filtered stream = False # forward to QGIS server url = permission['ogc_url'] if (ogc_service == 'WMS' and ((ogc_request == 'GETMAP' and params.get('FILENAME')) or ogc_request == 'GETPRINT')): # use any custom print URL when doing a # raster export (GetMap with FILENAME) or printing url = permission['print_url'] if method == 'POST': # log forward URL and params self.logger.info("Forward POST request to %s" % url) self.logger.info(" %s" % ("\n ").join( ("%s = %s" % (k, v) for k, v, in params.items()))) response = requests.post( url, headers={'host': urlparse(host_url).netloc}, data=params, stream=stream) else: # log forward URL and params self.logger.info("Forward GET request to %s?%s" % (url, urlencode(params))) response = requests.get( url, headers={'host': urlparse(host_url).netloc}, params=params, stream=stream) if response.status_code != requests.codes.ok: # handle internal server error self.logger.error("Internal Server Error:\n\n%s" % response.text) exception = { 'code': "UnknownError", 'message': "The server encountered an internal error or " "misconfiguration and was unable to complete your " "request." } return Response(self.service_exception(exception['code'], exception['message']), content_type='text/xml; charset=utf-8', status=200) # return filtered response elif ogc_service == 'WMS' and ogc_request in [ 'GETCAPABILITIES', 'GETPROJECTSETTINGS' ]: return wms_getcapabilities(response, host_url, params, script_root, permission) elif ogc_service == 'WMS' and ogc_request == 'GETFEATUREINFO': return wms_getfeatureinfo(response, params, permission) # TODO: filter DescribeFeatureInfo elif ogc_service == 'WFS' and ogc_request == 'GETCAPABILITIES': return wfs_getcapabilities(response, params, permission) elif ogc_service == 'WFS' and ogc_request == 'DESCRIBEFEATURETYPE': return wfs_describefeaturetype(response, params, permission) elif (ogc_service == 'WFS' and ogc_request == 'GETFEATURE' and len(params.get('TYPENAME', '').split(',')) > 1): # filter response if multiple layers requested return wfs_getfeature(response, params, permission) else: # unfiltered streamed response return Response(stream_with_context( response.iter_content(chunk_size=16 * 1024)), content_type=response.headers['content-type'], status=response.status_code) def load_resources(self, config): """Load service resources from config. :param RuntimeConfig config: Config handler """ wms_services = {} wfs_services = {} # collect WMS service resources for wms in config.resources().get('wms_services', []): # get any custom WMS URL wms_url = wms.get( 'wms_url', urljoin(self.default_qgis_server_url, wms['name'])) # get any custom online resources online_resources = wms.get('online_resources', {}) resources = { # WMS URL 'wms_url': wms_url, # custom online resources 'online_resources': { 'service': online_resources.get('service'), 'feature_info': online_resources.get('feature_info'), 'legend': online_resources.get('legend') }, # root layer name 'root_layer': wms['root_layer']['name'], # public layers without hidden sublayers: [<layers>] 'public_layers': [], # layers with available attributes: {<layer>: [<attrs>]} 'layers': {}, # queryable layers: [<layers>] 'queryable_layers': [], # layer aliases for feature info results: # {<feature info layer>: <layer>} 'feature_info_aliases': {}, # lookup for complete group layers # sub layers ordered from top to bottom: # {<group>: [<sub layers]} 'group_layers': {}, # custom opacities for hidden sublayers: # {<layer>: <opacity (0-100)>} 'hidden_sublayer_opacities': {}, # print URL, e.g. if using a separate QGIS project for printing 'print_url': wms.get('print_url', wms_url), # internal layers for printing: [<layers>] 'internal_print_layers': wms.get('internal_print_layers', []), # print templates: [<template name>] 'print_templates': wms.get('print_templates', []) } # collect WMS layers self.collect_layers(wms['root_layer'], resources, False) wms_services[wms['name']] = resources # collect WFS service resources for wfs in config.resources().get('wfs_services', []): # get any custom WFS URL wfs_url = wfs.get( 'wfs_url', urljoin(self.default_qgis_server_url, wfs['name'])) # collect WFS layers and attributes layers = {} for layer in wfs['layers']: layers[layer['name']] = layer.get('attributes', []) resources = { # WMS URL 'wfs_url': wfs_url, # custom online resource 'online_resource': wfs.get('online_resource'), # layers with available attributes: {<layer>: [<attrs>]} 'layers': layers } wfs_services[wfs['name']] = resources return {'wms_services': wms_services, 'wfs_services': wfs_services} def collect_layers(self, layer, resources, hidden): """Recursively collect layer info for layer subtree from config. :param obj layer: Layer or group layer :param obj resources: Partial lookups for layer resources :param bool hidden: Whether layer is a hidden sublayer """ if not hidden: resources['public_layers'].append(layer['name']) if layer.get('layers'): # group layer hidden |= layer.get('hide_sublayers', False) # collect sub layers queryable = False sublayers = [] for sublayer in layer['layers']: sublayers.append(sublayer['name']) # recursively collect sub layer self.collect_layers(sublayer, resources, hidden) if sublayer['name'] in resources['queryable_layers']: # group is queryable if any sub layer is queryable queryable = True resources['group_layers'][layer['name']] = sublayers if queryable: resources['queryable_layers'].append(layer['name']) else: # layer # attributes resources['layers'][layer['name']] = layer.get('attributes', []) if hidden and layer.get('opacity'): # add custom opacity for hidden sublayer resources['hidden_sublayer_opacities'][layer['name']] = \ layer.get('opacity') if layer.get('queryable', False) is True: resources['queryable_layers'].append(layer['name']) layer_title = layer.get('title', layer['name']) resources['feature_info_aliases'][layer_title] = layer['name'] def service_permissions(self, identity, service_name, ows_type): """Return permissions for a OGC service. :param str identity: User identity :param str service_name: OGC service name :param str ows_type: OWS type (WMS or WFS) """ self.logger.debug("Getting permissions for identity %s", identity) if ows_type == 'WMS': if not self.resources['wms_services'].get(service_name): # WMS service unknown return {} # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, service_name) if not wms_permissions: # WMS not permitted return {} wms_resources = self.resources['wms_services'][service_name].copy() # get available layers available_layers = set( list(wms_resources['layers'].keys()) + list(wms_resources['group_layers'].keys()) + wms_resources['internal_print_layers']) # combine permissions # permitted layers with permitted attributes: {<layer>: [<attrs>]} permitted_layers = {} permitted_print_templates = set() for permission in wms_permissions: # collect available and permitted layers for layer in permission['layers']: name = layer['name'] if name in available_layers: if name not in permitted_layers: # add permitted layer permitted_layers[name] = set() # collect available and permitted attributes attributes = [ attr for attr in layer.get('attributes', []) if attr in wms_resources['layers'][name] ] # add any attributes permitted_layers[name].update(attributes) # collect available and permitted print templates print_templates = [ template for template in permission.get('print_templates', []) if template in wms_resources['print_templates'] ] permitted_print_templates.update(print_templates) # filter by permissions public_layers = [ layer for layer in wms_resources['public_layers'] if layer in permitted_layers ] # layer attributes layers = {} for layer, attrs in wms_resources['layers'].items(): if layer in permitted_layers: # filter attributes by permissions layers[layer] = [ attr for attr in attrs if attr in permitted_layers[layer] ] queryable_layers = [ layer for layer in wms_resources['queryable_layers'] if layer in permitted_layers ] feature_info_aliases = {} for alias, layer in wms_resources['feature_info_aliases'].items(): if layer in permitted_layers: feature_info_aliases[alias] = layer # restricted group layers restricted_group_layers = {} # NOTE: always expand all group layers for group, sublayers in wms_resources['group_layers'].items(): if group in permitted_layers: # filter sublayers by permissions restricted_group_layers[group] = [ layer for layer in sublayers if layer in permitted_layers ] hidden_sublayer_opacities = {} for layer, opacity in wms_resources[ 'hidden_sublayer_opacities'].items(): if layer in permitted_layers: hidden_sublayer_opacities[layer] = opacity internal_print_layers = [ layer for layer in wms_resources['internal_print_layers'] if layer in permitted_layers ] print_templates = [ template for template in wms_resources['print_templates'] if template in permitted_print_templates ] return { 'service_name': service_name, # WMS URL 'ogc_url': wms_resources['wms_url'], # print URL 'print_url': wms_resources['print_url'], # custom online resource 'online_resources': wms_resources['online_resources'], # public layers without hidden sublayers 'public_layers': public_layers, # layers with permitted attributes 'layers': layers, # queryable layers 'queryable_layers': queryable_layers, # layer aliases for feature info results 'feature_info_aliases': feature_info_aliases, # lookup for group layers with restricted sublayers # sub layers ordered from top to bottom: # {<group>: [<sub layers>]} 'restricted_group_layers': restricted_group_layers, # custom opacities for hidden sublayers 'hidden_sublayer_opacities': hidden_sublayer_opacities, # internal layers for printing 'internal_print_layers': internal_print_layers, # print templates 'print_templates': print_templates } elif ows_type == 'WFS': if not self.resources['wfs_services'].get(service_name): # WFS service unknown return {} # get permissions for WFS wfs_permissions = self.permissions_handler.resource_permissions( 'wfs_services', identity, service_name) if not wfs_permissions: # WFS not permitted return {} wfs_resources = self.resources['wfs_services'][service_name].copy() # get available layers available_layers = set(list(wfs_resources['layers'].keys())) # combine permissions # permitted layers with permitted attributes: {<layer>: [<attrs>]} permitted_layers = {} for permission in wfs_permissions: # collect available and permitted layers for layer in permission['layers']: name = layer['name'] if name in available_layers: if name not in permitted_layers: # add permitted layer permitted_layers[name] = set() # collect available and permitted attributes attributes = [ attr for attr in layer.get('attributes', []) if attr in wfs_resources['layers'][name] ] # add any attributes permitted_layers[name].update(attributes) # filter by permissions public_layers = [ layer for layer in wfs_resources['layers'] if layer in permitted_layers ] # layer attributes layers = {} for layer, attrs in wfs_resources['layers'].items(): if layer in permitted_layers: # filter attributes by permissions layers[layer] = [ attr for attr in attrs if attr in permitted_layers[layer] ] return { 'service_name': service_name, # WFS URL 'ogc_url': wfs_resources['wfs_url'], # custom online resource 'online_resource': wfs_resources['online_resource'], # public layers 'public_layers': public_layers, # layers with permitted attributes 'layers': layers } # unsupported OWS type return {} def get_map_param_prefix(self, params): # Deduce map name by looking for param which ends with :EXTENT # (Can't look for param ending with :LAYERS as there might be i.e. A:LAYERS for the external layer definition A) mapname = "" for key, value in params.items(): if key.endswith(":EXTENT"): return key[0:-7] return ""
class QWC2Viewer: """QWC2Viewer class Provide configurations for QWC2 map viewer. """ # prefix for marking extracted Base64 encoded images in assets URL # e.g. '/assets/img/base64/mapthumbs/qwc_demo.png' BASE64_IMAGE_ROUTE_PREFIX = 'img/base64/' DEFAULT_THUMBNAIL_IMAGE = 'img/mapthumbs/default.jpg' def __init__(self, tenant, logger): """Constructor :param str tenant: Tenant ID :param Logger logger: Application logger """ self.tenant = tenant self.logger = logger config_handler = RuntimeConfig("mapViewer", logger) config = config_handler.tenant_config(tenant) # path to QWC2 files self.qwc2_path = config.get('qwc2_path', 'qwc2/') # QWC service URLs for config.json self.auth_service_url = self.__sanitize_url( config.get('auth_service_url')) self.ccc_config_service_url = self.__sanitize_url( config.get('ccc_config_service_url')) self.data_service_url = self.__sanitize_url( config.get('data_service_url')) self.dataproduct_service_url = self.__sanitize_url( config.get('dataproduct_service_url')) self.document_service_url = self.__sanitize_url( config.get('document_service_url', config.get('feature_report_service_url'))) self.elevation_service_url = self.__sanitize_url( config.get('elevation_service_url')) self.landreg_service_url = self.__sanitize_url( config.get('landreg_service_url')) self.mapinfo_service_url = self.__sanitize_url( config.get('mapinfo_service_url')) self.permalink_service_url = self.__sanitize_url( config.get('permalink_service_url')) self.plotinfo_service_url = self.__sanitize_url( config.get('plotinfo_service_url')) self.proxy_service_url = self.__sanitize_url( config.get('proxy_service_url')) self.search_service_url = self.__sanitize_url( config.get('search_service_url')) self.search_data_service_url = self.__sanitize_url( config.get('search_data_service_url')) # QWC service URLs for themes.json self.ogc_service_url = self.__sanitize_url( config.get('ogc_service_url', 'http://localhost:5013/')) self.info_service_url = self.__sanitize_url( config.get('info_service_url', self.ogc_service_url)) self.legend_service_url = self.__sanitize_url( config.get('legend_service_url', self.ogc_service_url)) self.print_service_url = self.__sanitize_url( config.get('print_service_url', self.ogc_service_url)) self.show_restricted_themes = config.get('show_restricted_themes', False) self.show_restricted_themes_whitelist = config.get('show_restricted_themes_whitelist', "") # get config dir for tenant self.config_dir = os.path.dirname( RuntimeConfig.config_file_path('mapViewer', tenant) ) # temporary target dir for any Base64 encoded thumbnail images # NOTE: this dir will be cleaned up automatically on reload self.images_temp_dir = None self.resources = self.load_resources(config) self.permissions_handler = PermissionsReader(tenant, logger) def qwc2_index(self, identity): """Return QWC2 index.html for user. :param obj identity: User identity """ # check if index file is present viewer_index_file = os.path.join(self.config_dir, 'index.html') try: with open(viewer_index_file) as fh: viewer_index = fh.read() except: # show FileNotFoundError error raise Exception( "[Errno 2] No such file or directory: '%s'" % viewer_index_file ) self.logger.debug("Using index '%s'" % viewer_index_file) # Inject CSRF token token = (get_jwt() or {}).get("csrf") if token: viewer_index = viewer_index.replace('</head>', '<meta name="csrf-token" content="%s" />\n</head>' % token) return viewer_index def qwc2_config(self, identity, params): """Return QWC2 config.json for user. :param obj identity: User identity """ self.logger.debug('Generating config.json for identity: %s', identity) # deep copy config from qwc2_config config = json.loads(json.dumps( self.resources['qwc2_config']['config'] )) # set QWC service URLs if self.auth_service_url: config['authServiceUrl'] = self.auth_service_url if self.ccc_config_service_url: config['cccConfigService'] = self.ccc_config_service_url if self.data_service_url: config['editServiceUrl'] = self.data_service_url if self.dataproduct_service_url: config['dataproductServiceUrl'] = self.dataproduct_service_url if self.document_service_url: config['featureReportService'] = self.document_service_url if self.elevation_service_url: config['elevationServiceUrl'] = self.elevation_service_url if self.landreg_service_url: config['landRegisterService'] = self.landreg_service_url if self.mapinfo_service_url: config['mapInfoService'] = self.mapinfo_service_url if self.permalink_service_url: config['permalinkServiceUrl'] = self.permalink_service_url if self.plotinfo_service_url: config['plotInfoService'] = self.plotinfo_service_url if self.proxy_service_url: config['proxyServiceUrl'] = self.proxy_service_url if self.search_service_url: config['searchServiceUrl'] = self.search_service_url if self.search_data_service_url: config['searchDataServiceUrl'] = self.search_data_service_url config['wmsDpi'] = os.environ.get( 'WMS_DPI', config.get('wmsDpi', '96')) username = None autologin = None if identity: if isinstance(identity, dict): username = identity.get('username') # NOTE: ignore group from identity autologin = identity.get('autologin') else: # identity is username username = identity # Look for any Login item, and change it to logout if user is signed in signed_in = username is not None autologin = (autologin is not None) or ( params.get("autologin") is not None) self.__replace_login__helper_plugins( config['plugins']['mobile'], signed_in, autologin) self.__replace_login__helper_plugins( config['plugins']['desktop'], signed_in, autologin) # filter any restricted viewer task items viewer_task_permissions = self.viewer_task_permissions(identity) self.__filter_restricted_viewer_tasks( config['plugins']['mobile'], viewer_task_permissions ) self.__filter_restricted_viewer_tasks( config['plugins']['desktop'], viewer_task_permissions ) config['username'] = username return jsonify(config) def __sanitize_url(self, url): """Ensure URL ends with a slash, if not empty """ return (url.rstrip('/') + '/') if url else "" def __replace_login__helper_plugins(self, plugins, signed_in, autologin): """Search plugins configurations and call self.__replace_login__helper_items on menuItems and toolbarItems :param list(obj) plugins: Plugins configurations :param bool signed_in: Whether user is signed in """ for plugin in plugins: if 'cfg' not in plugin: # skip plugin without cfg continue if "menuItems" in plugin["cfg"]: self.__replace_login__helper_items( plugin["cfg"]["menuItems"], signed_in, autologin) if "toolbarItems" in plugin["cfg"]: self.__replace_login__helper_items( plugin["cfg"]["toolbarItems"], signed_in, autologin) def __replace_login__helper_items(self, items, signed_in, autologin): """Replace Login with Logout if identity is not None on Login items in menuItems and toolbarItems. :param list(obj) items: Menu or toolbar items :param bool signed_in: Whether user is signed in """ removeIndex = None for (idx, item) in enumerate(items): if item["key"] == "Login" and signed_in: if autologin: removeIndex = idx break else: item["key"] = "Logout" item["icon"] = "logout" elif "subitems" in item: self.__replace_login__helper_items(item["subitems"], signed_in, autologin) if removeIndex is not None: del items[removeIndex] def __filter_restricted_viewer_tasks(self, plugins, viewer_task_permissions): """Remove restricted viewer task items from menu and toolbar. :param list(obj) plugins: Plugins configurations :param obj viewer_task_permissions: Viewer task permissions as {<item key>: <permitted>} """ for key in viewer_task_permissions: if not viewer_task_permissions[key]: for plugin in plugins: if 'cfg' not in plugin: # skip plugin without cfg continue if 'menuItems' in plugin['cfg']: self.__filter_config_items( plugin['cfg']['menuItems'], key ) if 'toolbarItems' in plugin['cfg']: self.__filter_config_items( plugin['cfg']['toolbarItems'], key ) def __filter_config_items(self, items, key): """Remove items with key from menuItems and toolbarItems. :param list(obj) items: Menu or toolbar items :param str key: Item key """ items_to_remove = [] for item in items: if item['key'] == key: # collect items to remove items_to_remove.append(item) elif 'subitems' in item: self.__filter_config_items(item['subitems'], key) for item in items_to_remove: items.remove(item) def qwc2_themes(self, identity): """Return QWC2 themes.json for user. :param obj identity: User identity """ self.logger.debug('Getting themes.json for identity: %s', identity) # filter by permissions themes = self.permitted_themes(identity) self.__update_service_urls(themes) return jsonify({"themes": themes}) def __update_service_urls(self, themes): for item in themes.get('items', []): if not item.get('wms_name'): continue wms_name = item['wms_name'] item.update({ 'url': "%s%s" % (self.ogc_service_url, wms_name), 'featureInfoUrl': "%s%s" % (self.info_service_url, wms_name), 'legendUrl': "%s%s?" % (self.legend_service_url, wms_name) + ( item["extraLegendParameters"] if "extraLegendParameters" in item else "") }) if item.get('print'): # add print URL only if print templates available item['printUrl'] = "%s%s" % (self.print_service_url, wms_name) for subdir in themes.get('subdirs', []): self.__update_service_urls(subdir) def qwc2_assets(self, path): """Return QWC2 asset from assets/ or temporary image dir. :param str path: Asset path """ if not path.startswith(self.BASE64_IMAGE_ROUTE_PREFIX): # send file from assets/ return send_from_directory( os.path.join(self.qwc2_path, 'assets'), path ) else: if self.images_temp_dir is not None: # send extracted Base64 encoded image (remove prefix) return send_from_directory( self.images_temp_dir.name, path[len(self.BASE64_IMAGE_ROUTE_PREFIX):] ) else: # temp dir not present return abort(404) def qwc2_js(self, path): """Return QWC2 Javascript from dist/. :param str path: Asset path """ return send_from_directory(os.path.join(self.qwc2_path, 'dist'), path) def qwc2_translations(self, path): """Return QWC2 translation file from translations/. :param str path: Asset path """ return send_from_directory( os.path.join(self.qwc2_path, 'translations'), path ) def qwc2_favicon(self): """Return default favicon.""" return send_from_directory(self.qwc2_path, 'favicon.ico') def load_resources(self, config): """Load service resources from config. :param RuntimeConfig config: Config handler """ # load QWC2 application config qwc2_config = config.resources().get('qwc2_config', {}) # load themes config qwc2_themes = config.resources().get('qwc2_themes', {}) # use contents of 'themes' qwc2_themes = qwc2_themes.get('themes', {}) # extract Base64 encoded thumbnail images self.extract_base64_theme_item_thumbnail_images(qwc2_themes) self.extract_base64_background_layer_thumbnail_images(qwc2_themes) return { 'qwc2_config': qwc2_config, 'qwc2_themes': qwc2_themes } def extract_base64_theme_item_thumbnail_images(self, theme_group): """Recursively extract any Base64 encoded theme item thumbnail images to files. :param obj theme_group: Theme group """ for item in theme_group.get('items', []): if 'thumbnail' not in item: image_path = None if 'thumbnail_base64' in item: image_path = self.extract_base64_thumbnail_image( item['name'], item['thumbnail_base64'] ) # remove thumbnail_base64 del item['thumbnail_base64'] if image_path is None: # set default if missing or error on extract image_path = self.DEFAULT_THUMBNAIL_IMAGE # update thumbnail path item['thumbnail'] = image_path if 'subdirs' in theme_group: for subgroup in theme_group['subdirs']: self.extract_base64_theme_item_thumbnail_images(subgroup) def extract_base64_background_layer_thumbnail_images(self, themes): """Extract any Base64 encoded background layer thumbnail images to files. :param obj themes: qwc2_themes """ for layer in themes.get('backgroundLayers', []): if 'thumbnail' not in layer: image_path = None if 'thumbnail_base64' in layer: image_path = self.extract_base64_thumbnail_image( "bg_%s" % layer['name'], layer['thumbnail_base64'] ) # remove thumbnail_base64 del layer['thumbnail_base64'] if image_path is None: # set default if missing or error on extract image_path = self.DEFAULT_THUMBNAIL_IMAGE # update thumbnail path layer['thumbnail'] = image_path def extract_base64_thumbnail_image(self, name, thumbnail_base64): """Extract Base64 encoded thumbnail image to file and return its assets path. :param str name: Image name :param str thumbnail_base64: Base64 encoded image """ image_path = None try: if self.images_temp_dir is None: # create temporary target dir self.images_temp_dir = tempfile.TemporaryDirectory( prefix='qwc-map-viewer-' ) os.makedirs( os.path.join(self.images_temp_dir.name, 'mapthumbs') ) # NOTE: do not add a random suffix so it may be cached in clients filename = "%s.png" % name # decode and save as image file file_path = os.path.join( self.images_temp_dir.name, 'mapthumbs', filename ) with open(file_path, 'wb') as f: f.write(base64.b64decode(thumbnail_base64)) # mark as extracted image for assets URL image_path = os.path.join( self.BASE64_IMAGE_ROUTE_PREFIX, 'mapthumbs', filename ) except Exception as e: image_path = None self.logger.error( "Could not extract Base64 encoded thumbnail image for '%s':" "\n%s" % (name, e) ) return image_path def viewer_task_permissions(self, identity): """Return permissions for viewer tasks. :param obj identity: User identity """ # get restricted viewer tasks restricted_viewer_tasks = self.resources['qwc2_config']. \ get('restricted_viewer_tasks', []) # get permitted viewer tasks permitted_viewer_tasks = self.permissions_handler.resource_permissions( 'viewer_tasks', identity ) # unique set permitted_viewer_tasks = set(permitted_viewer_tasks) # set permissions viewer_tasks = {} for viewer_task in restricted_viewer_tasks: viewer_tasks[viewer_task] = viewer_task in permitted_viewer_tasks return viewer_tasks def permitted_themes(self, identity): """Return qwc2_themes filtered by permissions. :param obj identity: User identity """ # deep copy qwc2_themes themes = json.loads(json.dumps(self.resources['qwc2_themes'])) # filter theme items by permissions items = [] for item in themes['items']: permitted_item = self.permitted_theme_item(item, identity) if permitted_item: items.append(permitted_item) else: self.add_restricted_item(items, item) themes['items'] = items # filter theme groups by permissions groups = [] for group in themes['subdirs']: permitted_group = self.permitted_theme_group(group, identity) if permitted_group: groups.append(permitted_group) # filter background layers by permissions self.filter_background_layers(themes, identity) # filter unused external layers self.filter_external_layers(themes) # filter unused theme info links self.filter_theme_info_links(themes) # filter unused plugin data self.filter_plugin_data(themes) return themes def permitted_theme_group(self, theme_group, identity): """Return theme group filtered by permissions. :param obj theme_group: Theme group :param obj identity: User identity """ # collect theme items items = [] for item in theme_group['items']: permitted_item = self.permitted_theme_item(item, identity) if permitted_item: items.append(permitted_item) else: self.add_restricted_item(items, item) theme_group['items'] = items # collect sub groups subgroups = [] for subgroup in theme_group['subdirs']: # recursively filter sub group permitted_subgroup = self.permitted_theme_group(subgroup, identity) if permitted_subgroup: subgroups.append(permitted_subgroup) theme_group['subdirs'] = subgroups if not items and not subgroups: # remove empty theme group return None return theme_group def add_restricted_item(self, items, item): """Add restricted theme item placeholders if enabled by configuration :param obj items: Items list to which to add the placeholder to :param obj item: The item for which to add the placeholder """ if not self.show_restricted_themes: return if self.show_restricted_themes_whitelist and not item["name"] in self.show_restricted_themes_whitelist: return items.append({ "id": item["id"], "name": item["name"], "title": item["title"], "thumbnail": item["thumbnail"], "restricted": True }) def permitted_theme_item(self, item, identity): """Return theme item filtered by permissions. :param obj item: Theme item :param obj identity: User identity """ # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, item['wms_name'] ) if not wms_permissions: # WMS not permitted return None # combine permissions permitted_layers = set() permitted_print_templates = set() for permission in wms_permissions: # collect permitted layers permitted_layers.update([ layer['name'] for layer in permission['layers'] ]) # collect permitted print templates permitted_print_templates.update( permission.get('print_templates', []) ) # filter by permissions self.filter_restricted_layers(item, permitted_layers) self.filter_print_templates(item, permitted_print_templates) self.filter_edit_config(item, identity) self.filter_item_background_layers(item, identity) self.filter_item_search_providers(item, identity) self.filter_item_external_layers(item, permitted_layers) self.filter_item_theme_info_links(item, identity) self.filter_item_plugin_data(item, identity) return item def filter_restricted_layers(self, layer, permitted_layers): """Recursively filter layers by permissions. :param obj layer: Layer or group layer :param set permitted_layers: List of permitted layers """ if layer.get('sublayers'): # group layer # collect permitted sub layers sublayers = [] for sublayer in layer['sublayers']: # check permissions if sublayer['name'] in permitted_layers: # recursively filter sub layer self.filter_restricted_layers(sublayer, permitted_layers) sublayers.append(sublayer) layer['sublayers'] = sublayers def filter_print_templates(self, item, permitted_print_templates): """Filter print templates by permissions. :param obj item: Theme item :param set permitted_print_templates: List of permitted print templates """ print_templates = [ template for template in item.get('print', []) if template['name'] in permitted_print_templates ] if print_templates: item['print'] = print_templates else: # no print templates permitted # remove print configs item.pop('print', None) item.pop('printUrl', None) item.pop('printScales', None) item.pop('printResolutions', None) item.pop('printGrid', None) item.pop('printLabelConfig', None) item.pop('printLabelForSearchResult', None) for bg in item.get('backgroundLayers', []): bg.pop('printLayer', None) def filter_edit_config(self, item, identity): """Filter edit config by permissions. :param obj item: Theme item :param obj identity: User identity """ if not item.get('editConfig'): # no edit config or blank return # collect permitted edit datasets edit_config = {} for name, config in item.get('editConfig').items(): # dataset name from editDataset or WMS and name dataset = "%s.%s" % (item['wms_name'], name) dataset = config.get('editDataset', dataset) permitted_dataset = self.permitted_dataset( dataset, config, identity ) if permitted_dataset: edit_config[name] = permitted_dataset if edit_config: item['editConfig'] = edit_config else: # no permitted datasets item['editConfig'] = None def permitted_dataset(self, dataset, config, identity): """Return edit dataset filtered by permissions. :param str dataset: Dataset ID :param obj config: Edit dataset config :param obj identity: User identity """ # get permissions for edit dataset dataset_permissions = self.permissions_handler.resource_permissions( 'data_datasets', identity, dataset ) if not dataset_permissions: # edit dataset not permitted return None # combine permissions permitted_attributes = set() for permission in dataset_permissions: # collect permitted attributes permitted_attributes.update(permission.get('attributes', [])) # filter attributes by permissions config['fields'] = [ field for field in config.get('fields', []) if field['id'] in permitted_attributes ] return config def filter_background_layers(self, themes, identity): """Filter available background layers by permissions. :param obj themes: qwc2_themes :param obj identity: User identity """ # get permissions for background layers permitted_bg_layers = self.permissions_handler.resource_permissions( 'background_layers', identity ) # filter background layers by permissions themes['backgroundLayers'] = [ layer for layer in themes['backgroundLayers'] if layer['name'] in permitted_bg_layers ] def filter_item_background_layers(self, item, identity): """Filter theme item background layers by permissions. :param obj item: Theme item :param obj identity: User identity """ if not item.get('backgroundLayers'): # no background layers return # get permissions for background layers permitted_bg_layers = self.permissions_handler.resource_permissions( 'background_layers', identity ) # filter background layers by permissions item['backgroundLayers'] = [ layer for layer in item['backgroundLayers'] if layer['name'] in permitted_bg_layers ] def filter_item_search_providers(self, item, identity): """Filter theme item search providers by permissions. :param obj item: Theme item :param obj identity: User identity """ if 'searchProviders' in item: # get permissions for Solr facets permitted_solr_facets = \ self.permissions_handler.resource_permissions( 'solr_facets', identity ) for search_provider in item['searchProviders']: if ( 'provider' in search_provider and search_provider['provider'] == 'solr' ): # filter Solr facets by permissions if 'default' in search_provider: search_provider['default'] = [ facet for facet in search_provider['default'] if facet in permitted_solr_facets ] if 'layers' in search_provider: layers = {} for layer, facet in search_provider['layers'].items(): if facet in permitted_solr_facets: layers[layer] = facet if layers: search_provider['layers'] = layers else: # remove if no layer search permitted del search_provider['layers'] # filter layer searchterms self.filter_layer_searchterms(item, permitted_solr_facets) def filter_layer_searchterms(self, layer, permitted_solr_facets): """Recursively filter layer searchterms by permissions. :param obj layer: Layer or group layer :param set permitted_solr_facets: List of permitted Solr facets """ if layer.get('sublayers'): # group layer for sublayer in layer['sublayers']: # recursively filter sub layer self.filter_layer_searchterms(sublayer, permitted_solr_facets) else: # data layer if 'searchterms' in layer: # filter searchterms by permissions searchterms = [ facet for facet in layer['searchterms'] if facet in permitted_solr_facets ] if searchterms: layer['searchterms'] = searchterms else: # remove if no layer search permitted del layer['searchterms'] def filter_external_layers(self, themes): """Filter unused external layers. :param obj themes: qwc2_themes """ if 'externalLayers' in themes: # collect used external layer names external_layers = self.collect_external_layers(themes) # filter unused external layers themes["externalLayers"] = [ layer for layer in themes["externalLayers"] if layer['name'] in external_layers ] def collect_external_layers(self, theme_group): """Recursively collect used external layer names. :param obj theme_group: Theme group """ external_layers = set() for item in theme_group['items']: for layer in item.get('externalLayers', []): external_layers.add(layer.get('name')) if 'subdirs' in theme_group: for subgroup in theme_group['subdirs']: external_layers.update(self.collect_external_layers(subgroup)) return external_layers def filter_item_external_layers(self, item, permitted_layers): """Filter theme item external layers by permissions. :param obj item: Theme item :param set permitted_layers: List of permitted layers """ if 'externalLayers' in item: # filter external layers by permissions item['externalLayers'] = [ layer for layer in item['externalLayers'] if layer.get('internalLayer') in permitted_layers ] def filter_theme_info_links(self, themes): """Filter unused theme info links. :param obj themes: qwc2_themes """ if 'themeInfoLinks' in themes: # collect used theme info links theme_info_links = self.collect_theme_info_links(themes) # filter unused theme info links themes["themeInfoLinks"] = [ theme_info_link for theme_info_link in themes["themeInfoLinks"] if theme_info_link.get('name') in theme_info_links ] def collect_theme_info_links(self, theme_group): """Recursively collect used theme info link entries. :param obj theme_group: Theme group """ theme_info_links = set() for item in theme_group['items']: for entry in item.get('themeInfoLinks', {}).get('entries', []): theme_info_links.add(entry) if 'subdirs' in theme_group: for subgroup in theme_group['subdirs']: theme_info_links.update( self.collect_theme_info_links(subgroup) ) return theme_info_links def filter_item_theme_info_links(self, item, identity): """Filter theme item theme info links by permissions. :param obj item: Theme item :param obj identity: User identity """ if 'themeInfoLinks' in item: # get permissions for theme info links permitted_theme_info_links = \ self.permissions_handler.resource_permissions( 'theme_info_links', identity ) # filter theme info links by permissions entries = [ entry for entry in item['themeInfoLinks'].get('entries', []) if entry in permitted_theme_info_links ] if entries: item['themeInfoLinks']['entries'] = entries else: # remove if no entries permitted del item['themeInfoLinks'] def filter_plugin_data(self, themes): """Filter unused plugin data. :param obj themes: qwc2_themes """ if 'pluginData' in themes: # collect used plugin data plugin_data = self.collect_plugin_data(themes) # filter unused plugin data themes_plugin_data = {} for plugin, resources in themes["pluginData"].items(): if plugin in plugin_data: # filter plugin specific resources resources = [ resource for resource in resources if resource.get('name') in plugin_data[plugin] ] if resources: themes_plugin_data[plugin] = resources themes["pluginData"] = themes_plugin_data def collect_plugin_data(self, theme_group): """Recursively collect used plugin data names. :param obj theme_group: Theme group """ plugin_data = {} for item in theme_group['items']: for plugin, resources in item.get('pluginData', {}).items(): if plugin not in plugin_data: plugin_data[plugin] = set() plugin_data[plugin].update(resources) if 'subdirs' in theme_group: for subgroup in theme_group['subdirs']: sub_plugin_data = self.collect_plugin_data(subgroup) for plugin, resources in sub_plugin_data.items(): if plugin not in plugin_data: plugin_data[plugin] = set() plugin_data[plugin].update(resources) return plugin_data def filter_item_plugin_data(self, item, identity): """Filter theme item plugin data by permissions. :param obj item: Theme item :param obj identity: User identity """ if 'pluginData' in item: # get permissions for theme plugin data permitted_plugin_data = \ self.permissions_handler.resource_permissions( 'plugin_data', identity ) # lookup for combined permissions by plugin plugin_permissions = {} for permission in permitted_plugin_data: # collect permitted plugin resources plugin = permission.get('name') if plugin not in plugin_permissions: plugin_permissions[plugin] = set() plugin_permissions[plugin].update( permission.get('resources', []) ) # filter plugin data by permissions plugin_data = {} for plugin, resources in item['pluginData'].items(): if plugin in plugin_permissions: # filter plugin specific resources resources = [ resource for resource in resources if resource in plugin_permissions[plugin] ] if resources: plugin_data[plugin] = resources if plugin_data: item['pluginData'] = plugin_data else: # remove if no plugin data permitted del item['pluginData']
class QWC2Viewer: """QWC2Viewer class Provide configurations for QWC2 map viewer. """ def __init__(self, tenant, logger): """Constructor :param str tenant: Tenant ID :param Logger logger: Application logger """ self.tenant = tenant self.logger = logger config_handler = RuntimeConfig("mapViewer", logger) config = config_handler.tenant_config(tenant) # path to QWC2 files self.qwc2_path = config.get('qwc2_path', 'qwc2/') # QWC service URLs for config.json self.auth_service_url = self.__sanitize_url( config.get('auth_service_url')) self.ccc_config_service_url = self.__sanitize_url( config.get('ccc_config_service_url')) self.data_service_url = self.__sanitize_url( config.get('data_service_url')) self.dataproduct_service_url = self.__sanitize_url( config.get('dataproduct_service_url')) self.document_service_url = self.__sanitize_url( config.get('document_service_url', config.get('feature_report_service_url'))) self.elevation_service_url = self.__sanitize_url( config.get('elevation_service_url')) self.landreg_service_url = self.__sanitize_url( config.get('landreg_service_url')) self.mapinfo_service_url = self.__sanitize_url( config.get('mapinfo_service_url')) self.permalink_service_url = self.__sanitize_url( config.get('permalink_service_url')) self.plotinfo_service_url = self.__sanitize_url( config.get('plotinfo_service_url')) self.proxy_service_url = self.__sanitize_url( config.get('proxy_service_url')) self.search_service_url = self.__sanitize_url( config.get('search_service_url')) self.search_data_service_url = self.__sanitize_url( config.get('search_data_service_url')) # QWC service URLs for themes.json self.ogc_service_url = self.__sanitize_url( config.get('ogc_service_url', 'http://localhost:5013/')) self.info_service_url = self.__sanitize_url( config.get('info_service_url', self.ogc_service_url)) self.legend_service_url = self.__sanitize_url( config.get('legend_service_url', self.ogc_service_url)) self.print_service_url = self.__sanitize_url( config.get('print_service_url', self.ogc_service_url)) # get config dir for tenant self.config_dir = os.path.dirname( RuntimeConfig.config_file_path('mapViewer', tenant)) self.resources = self.load_resources(config) self.permissions_handler = PermissionsReader(tenant, logger) def qwc2_index(self, identity): """Return QWC2 index.html for user. :param obj identity: User identity """ # check if index file is present viewer_index_file = os.path.join(self.config_dir, 'index.html') if not os.path.isfile(viewer_index_file): # show FileNotFoundError error raise Exception("[Errno 2] No such file or directory: '%s'" % viewer_index_file) # send index.html from config dir self.logger.debug("Using index '%s'" % viewer_index_file) return send_from_directory(self.config_dir, 'index.html') def qwc2_config(self, identity): """Return QWC2 config.json for user. :param obj identity: User identity """ self.logger.debug('Generating config.json for identity: %s', identity) # deep copy config from qwc2_config config = json.loads(json.dumps( self.resources['qwc2_config']['config'])) # set QWC service URLs if self.auth_service_url: config['authServiceUrl'] = self.auth_service_url if self.ccc_config_service_url: config['cccConfigService'] = self.ccc_config_service_url if self.data_service_url: config['editServiceUrl'] = self.data_service_url if self.dataproduct_service_url: config['dataproductServiceUrl'] = self.dataproduct_service_url if self.document_service_url: config['featureReportService'] = self.document_service_url if self.elevation_service_url: config['elevationServiceUrl'] = self.elevation_service_url if self.landreg_service_url: config['landRegisterService'] = self.landreg_service_url if self.mapinfo_service_url: config['mapInfoService'] = self.mapinfo_service_url if self.permalink_service_url: config['permalinkServiceUrl'] = self.permalink_service_url if self.plotinfo_service_url: config['plotInfoService'] = self.plotinfo_service_url if self.proxy_service_url: config['proxyServiceUrl'] = self.proxy_service_url if self.search_service_url: config['searchServiceUrl'] = self.search_service_url if self.search_data_service_url: config['searchDataServiceUrl'] = self.search_data_service_url config['wmsDpi'] = os.environ.get('WMS_DPI', config.get('wmsDpi', '96')) username = None if identity: if isinstance(identity, dict): username = identity.get('username') # NOTE: ignore group from identity else: # identity is username username = identity # Look for any Login item, and change it to logout if user is signed in signed_in = username is not None self.__replace_login__helper_plugins(config['plugins']['mobile'], signed_in) self.__replace_login__helper_plugins(config['plugins']['desktop'], signed_in) # filter any restricted viewer task items viewer_task_permissions = self.viewer_task_permissions(identity) self.__filter_restricted_viewer_tasks(config['plugins']['mobile'], viewer_task_permissions) self.__filter_restricted_viewer_tasks(config['plugins']['desktop'], viewer_task_permissions) config['username'] = username return jsonify(config) def __sanitize_url(self, url): """Ensure URL ends with a slash, if not empty """ return (url.rstrip('/') + '/') if url else "" def __replace_login__helper_plugins(self, plugins, signed_in): """Search plugins configurations and call self.__replace_login__helper_items on menuItems and toolbarItems :param list(obj) plugins: Plugins configurations :param bool signed_in: Whether user is signed in """ for plugin in plugins: if 'cfg' not in plugin: # skip plugin without cfg continue if "menuItems" in plugin["cfg"]: self.__replace_login__helper_items(plugin["cfg"]["menuItems"], signed_in) if "toolbarItems" in plugin["cfg"]: self.__replace_login__helper_items( plugin["cfg"]["toolbarItems"], signed_in) def __replace_login__helper_items(self, items, signed_in): """Replace Login with Logout if identity is not None on Login items in menuItems and toolbarItems. :param list(obj) items: Menu or toolbar items :param bool signed_in: Whether user is signed in """ for item in items: if item["key"] == "Login" and signed_in: item["key"] = "Logout" item["icon"] = "logout" elif "subitems" in item: self.__replace_login__helper_items(item["subitems"], signed_in) def __filter_restricted_viewer_tasks(self, plugins, viewer_task_permissions): """Remove restricted viewer task items from menu and toolbar. :param list(obj) plugins: Plugins configurations :param obj viewer_task_permissions: Viewer task permissions as {<item key>: <permitted>} """ for key in viewer_task_permissions: if not viewer_task_permissions[key]: for plugin in plugins: if 'cfg' not in plugin: # skip plugin without cfg continue if 'menuItems' in plugin['cfg']: self.__filter_config_items(plugin['cfg']['menuItems'], key) if 'toolbarItems' in plugin['cfg']: self.__filter_config_items( plugin['cfg']['toolbarItems'], key) def __filter_config_items(self, items, key): """Remove items with key from menuItems and toolbarItems. :param list(obj) items: Menu or toolbar items :param str key: Item key """ items_to_remove = [] for item in items: if item['key'] == key: # collect items to remove items_to_remove.append(item) elif 'subitems' in item: self.__filter_config_items(item['subitems'], key) for item in items_to_remove: items.remove(item) def qwc2_themes(self, identity): """Return QWC2 themes.json for user. :param obj identity: User identity """ self.logger.debug('Getting themes.json for identity: %s', identity) # filter by permissions themes = self.permitted_themes(identity) for item in themes.get('items', []): # update service URLs wms_name = item['wms_name'] item.update({ 'url': "%s%s" % (self.ogc_service_url, wms_name), 'featureInfoUrl': "%s%s" % (self.info_service_url, wms_name), 'legendUrl': "%s%s" % (self.legend_service_url, wms_name) }) if item.get('print'): # add print URL only if print templates available item['printUrl'] = "%s%s" % (self.print_service_url, wms_name) subdirs = themes.get('subdirs', []) self.__update_subdir_urls(subdirs, self.ogc_service_url, self.info_service_url, self.legend_service_url, self.print_service_url) return jsonify({"themes": themes}) def __update_subdir_urls(self, subdirs, ogc_server_url, info_service_url, legend_service_url, print_service_url): for subdir in subdirs: if 'items' in subdir: for item in subdir['items']: wms_name = item['wms_name'] item.update({ 'url': "%s%s" % (ogc_server_url, wms_name), 'featureInfoUrl': "%s%s" % (info_service_url, wms_name), 'legendUrl': "%s%s" % (legend_service_url, wms_name) }) if item.get('print'): # add print URL only if print templates available item['printUrl'] = "%s%s" % (print_service_url, wms_name) if 'subdirs' in subdir: self.__update_subdir_urls(subdir['subdirs'], ogc_server_url, info_service_url, legend_service_url, print_service_url) def qwc2_assets(self, path): """Return QWC2 asset from assets/. :param str path: Asset path """ return send_from_directory(os.path.join(self.qwc2_path, 'assets'), path) def qwc2_js(self, path): """Return QWC2 Javascript from dist/. :param str path: Asset path """ return send_from_directory(os.path.join(self.qwc2_path, 'dist'), path) def qwc2_translations(self, path): """Return QWC2 translation file from translations/. :param str path: Asset path """ return send_from_directory( os.path.join(self.qwc2_path, 'translations'), path) def qwc2_favicon(self): """Return default favicon.""" return send_from_directory(self.qwc2_path, 'favicon.ico') def load_resources(self, config): """Load service resources from config. :param RuntimeConfig config: Config handler """ # load QWC2 application config qwc2_config = config.resources().get('qwc2_config', {}) # load themes config qwc2_themes = config.resources().get('qwc2_themes', {}) # use contents of 'themes' qwc2_themes = qwc2_themes.get('themes', {}) return {'qwc2_config': qwc2_config, 'qwc2_themes': qwc2_themes} def viewer_task_permissions(self, identity): """Return permissions for viewer tasks. :param obj identity: User identity """ # get restricted viewer tasks restricted_viewer_tasks = self.resources['qwc2_config']. \ get('restricted_viewer_tasks', []) # get permitted viewer tasks permitted_viewer_tasks = self.permissions_handler.resource_permissions( 'viewer_tasks', identity) # unique set permitted_viewer_tasks = set(permitted_viewer_tasks) # set permissions viewer_tasks = {} for viewer_task in restricted_viewer_tasks: viewer_tasks[viewer_task] = viewer_task in permitted_viewer_tasks return viewer_tasks def permitted_themes(self, identity): """Return qwc2_themes filtered by permissions. :param obj identity: User identity """ # deep copy qwc2_themes themes = json.loads(json.dumps(self.resources['qwc2_themes'])) # filter theme items by permissions items = [] for item in themes['items']: permitted_item = self.permitted_theme_item(item, identity) if permitted_item: items.append(permitted_item) themes['items'] = items # filter theme groups by permissions groups = [] for group in themes['subdirs']: permitted_group = self.permitted_theme_group(group, identity) if permitted_group: groups.append(permitted_group) # filter background layers by permissions self.filter_background_layers(themes, identity) # filter unused external layers self.filter_external_layers(themes) # filter unused theme info links self.filter_theme_info_links(themes) # filter unused plugin data self.filter_plugin_data(themes) return themes def permitted_theme_group(self, theme_group, identity): """Return theme group filtered by permissions. :param obj theme_group: Theme group :param obj identity: User identity """ # collect theme items items = [] for item in theme_group['items']: permitted_item = self.permitted_theme_item(item, identity) if permitted_item: items.append(permitted_item) theme_group['items'] = items # collect sub groups subgroups = [] for subgroup in theme_group['subdirs']: # recursively filter sub group permitted_subgroup = self.permitted_theme_group(subgroup, identity) if permitted_subgroup: subgroups.append(permitted_subgroup) theme_group['subdirs'] = subgroups if not items and not subgroups: # remove empty theme group return None return theme_group def permitted_theme_item(self, item, identity): """Return theme item filtered by permissions. :param obj item: Theme item :param obj identity: User identity """ # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, item['wms_name']) if not wms_permissions: # WMS not permitted return None # combine permissions permitted_layers = set() permitted_print_templates = set() for permission in wms_permissions: # collect permitted layers permitted_layers.update( [layer['name'] for layer in permission['layers']]) # collect permitted print templates permitted_print_templates.update( permission.get('print_templates', [])) # filter by permissions self.filter_restricted_layers(item, permitted_layers) self.filter_print_templates(item, permitted_print_templates) self.filter_edit_config(item, identity) self.filter_item_background_layers(item, identity) self.filter_item_search_providers(item, identity) self.filter_item_external_layers(item, permitted_layers) self.filter_item_theme_info_links(item, identity) self.filter_item_plugin_data(item, identity) return item def filter_restricted_layers(self, layer, permitted_layers): """Recursively filter layers by permissions. :param obj layer: Layer or group layer :param set permitted_layers: List of permitted layers """ if layer.get('sublayers'): # group layer # collect permitted sub layers sublayers = [] for sublayer in layer['sublayers']: # check permissions if sublayer['name'] in permitted_layers: # recursively filter sub layer self.filter_restricted_layers(sublayer, permitted_layers) sublayers.append(sublayer) layer['sublayers'] = sublayers def filter_print_templates(self, item, permitted_print_templates): """Filter print templates by permissions. :param obj item: Theme item :param set permitted_print_templates: List of permitted print templates """ print_templates = [ template for template in item.get('print', []) if template['name'] in permitted_print_templates ] if print_templates: item['print'] = print_templates else: # no print templates permitted # remove print configs item.pop('print', None) item.pop('printUrl', None) item.pop('printScales', None) item.pop('printResolutions', None) item.pop('printGrid', None) item.pop('printLabelConfig', None) item.pop('printLabelForSearchResult', None) for bg in item.get('backgroundLayers', []): bg.pop('printLayer', None) def filter_edit_config(self, item, identity): """Filter edit config by permissions. :param obj item: Theme item :param obj identity: User identity """ if not item.get('editConfig'): # no edit config or blank return # collect permitted edit datasets edit_config = {} for name, config in item.get('editConfig').items(): # dataset name from editDataset or WMS and name dataset = "%s.%s" % (item['wms_name'], name) dataset = config.get('editDataset', dataset) permitted_dataset = self.permitted_dataset(dataset, config, identity) if permitted_dataset: edit_config[name] = permitted_dataset if edit_config: item['editConfig'] = edit_config else: # no permitted datasets item['editConfig'] = None def permitted_dataset(self, dataset, config, identity): """Return edit dataset filtered by permissions. :param str dataset: Dataset ID :param obj config: Edit dataset config :param obj identity: User identity """ # get permissions for edit dataset dataset_permissions = self.permissions_handler.resource_permissions( 'data_datasets', identity, dataset) if not dataset_permissions: # edit dataset not permitted return None # combine permissions permitted_attributes = set() for permission in dataset_permissions: # collect permitted attributes permitted_attributes.update(permission.get('attributes', [])) # filter attributes by permissions config['fields'] = [ field for field in config.get('fields', []) if field['name'] in permitted_attributes ] return config def filter_background_layers(self, themes, identity): """Filter available background layers by permissions. :param obj themes: qwc2_themes :param obj identity: User identity """ # get permissions for background layers permitted_bg_layers = self.permissions_handler.resource_permissions( 'background_layers', identity) # filter background layers by permissions themes['backgroundLayers'] = [ layer for layer in themes['backgroundLayers'] if layer['name'] in permitted_bg_layers ] def filter_item_background_layers(self, item, identity): """Filter theme item background layers by permissions. :param obj item: Theme item :param obj identity: User identity """ if not item.get('backgroundLayers'): # no background layers return # get permissions for background layers permitted_bg_layers = self.permissions_handler.resource_permissions( 'background_layers', identity) # filter background layers by permissions item['backgroundLayers'] = [ layer for layer in item['backgroundLayers'] if layer['name'] in permitted_bg_layers ] def filter_item_search_providers(self, item, identity): """Filter theme item search providers by permissions. :param obj item: Theme item :param obj identity: User identity """ if 'searchProviders' in item: # get permissions for Solr facets permitted_solr_facets = \ self.permissions_handler.resource_permissions( 'solr_facets', identity ) for search_provider in item['searchProviders']: if ('provider' in search_provider and search_provider['provider'] == 'solr'): # filter Solr facets by permissions if 'default' in search_provider: search_provider['default'] = [ facet for facet in search_provider['default'] if facet in permitted_solr_facets ] if 'layers' in search_provider: layers = {} for layer, facet in search_provider['layers'].items(): if facet in permitted_solr_facets: layers[layer] = facet if layers: search_provider['layers'] = layers else: # remove if no layer search permitted del search_provider['layers'] # filter layer searchterms self.filter_layer_searchterms(item, permitted_solr_facets) def filter_layer_searchterms(self, layer, permitted_solr_facets): """Recursively filter layer searchterms by permissions. :param obj layer: Layer or group layer :param set permitted_solr_facets: List of permitted Solr facets """ if layer.get('sublayers'): # group layer for sublayer in layer['sublayers']: # recursively filter sub layer self.filter_layer_searchterms(sublayer, permitted_solr_facets) else: # data layer if 'searchterms' in layer: # filter searchterms by permissions searchterms = [ facet for facet in layer['searchterms'] if facet in permitted_solr_facets ] if searchterms: layer['searchterms'] = searchterms else: # remove if no layer search permitted del layer['searchterms'] def filter_external_layers(self, themes): """Filter unused external layers. :param obj themes: qwc2_themes """ if 'externalLayers' in themes: # collect used external layer names external_layers = self.collect_external_layers(themes) # filter unused external layers themes["externalLayers"] = [ layer for layer in themes["externalLayers"] if layer['name'] in external_layers ] def collect_external_layers(self, theme_group): """Recursively collect used external layer names. :param obj theme_group: Theme group """ external_layers = set() for item in theme_group['items']: for layer in item.get('externalLayers', []): external_layers.add(layer.get('name')) if 'subdirs' in theme_group: for subgroup in theme_group['subdirs']: external_layers.update(self.collect_external_layers(subgroup)) return external_layers def filter_item_external_layers(self, item, permitted_layers): """Filter theme item external layers by permissions. :param obj item: Theme item :param set permitted_layers: List of permitted layers """ if 'externalLayers' in item: # filter external layers by permissions item['externalLayers'] = [ layer for layer in item['externalLayers'] if layer.get('internalLayer') in permitted_layers ] def filter_theme_info_links(self, themes): """Filter unused theme info links. :param obj themes: qwc2_themes """ if 'themeInfoLinks' in themes: # collect used theme info links theme_info_links = self.collect_theme_info_links(themes) # filter unused theme info links themes["themeInfoLinks"] = [ theme_info_link for theme_info_link in themes["themeInfoLinks"] if theme_info_link.get('name') in theme_info_links ] def collect_theme_info_links(self, theme_group): """Recursively collect used theme info link entries. :param obj theme_group: Theme group """ theme_info_links = set() for item in theme_group['items']: for entry in item.get('themeInfoLinks', {}).get('entries', []): theme_info_links.add(entry) if 'subdirs' in theme_group: for subgroup in theme_group['subdirs']: theme_info_links.update( self.collect_theme_info_links(subgroup)) return theme_info_links def filter_item_theme_info_links(self, item, identity): """Filter theme item theme info links by permissions. :param obj item: Theme item :param obj identity: User identity """ if 'themeInfoLinks' in item: # get permissions for theme info links permitted_theme_info_links = \ self.permissions_handler.resource_permissions( 'theme_info_links', identity ) # filter theme info links by permissions entries = [ entry for entry in item['themeInfoLinks'].get('entries', []) if entry in permitted_theme_info_links ] if entries: item['themeInfoLinks']['entries'] = entries else: # remove if no entries permitted del item['themeInfoLinks'] def filter_plugin_data(self, themes): """Filter unused plugin data. :param obj themes: qwc2_themes """ if 'pluginData' in themes: # collect used plugin data plugin_data = self.collect_plugin_data(themes) # filter unused plugin data themes_plugin_data = {} for plugin, resources in themes["pluginData"].items(): if plugin in plugin_data: # filter plugin specific resources resources = [ resource for resource in resources if resource.get('name') in plugin_data[plugin] ] if resources: themes_plugin_data[plugin] = resources themes["pluginData"] = themes_plugin_data def collect_plugin_data(self, theme_group): """Recursively collect used plugin data names. :param obj theme_group: Theme group """ plugin_data = {} for item in theme_group['items']: for plugin, resources in item.get('pluginData', {}).items(): if plugin not in plugin_data: plugin_data[plugin] = set() plugin_data[plugin].update(resources) if 'subdirs' in theme_group: for subgroup in theme_group['subdirs']: sub_plugin_data = self.collect_plugin_data(subgroup) for plugin, resources in sub_plugin_data.items(): if plugin not in plugin_data: plugin_data[plugin] = set() plugin_data[plugin].update(resources) return plugin_data def filter_item_plugin_data(self, item, identity): """Filter theme item plugin data by permissions. :param obj item: Theme item :param obj identity: User identity """ if 'pluginData' in item: # get permissions for theme info links permitted_plugin_data = \ self.permissions_handler.resource_permissions( 'plugin_data', identity ) # lookup for permissions by plugin plugin_permissions = {} for permission in permitted_plugin_data: plugin_permissions[permission.get('name')] = \ permission.get('resources', []) # filter plugin data by permissions plugin_data = {} for plugin, resources in item['pluginData'].items(): if plugin in plugin_permissions: # filter plugin specific resources resources = [ resource for resource in resources if resource in plugin_permissions[plugin] ] if resources: plugin_data[plugin] = resources if plugin_data: item['pluginData'] = plugin_data else: # remove if no plugin data permitted del item['pluginData']
class SolrClient: """SolrClient class """ def __init__(self, tenant, logger): """Constructor :param Logger logger: Application logger """ self.logger = logger self.tenant = tenant config_handler = RuntimeConfig("search", logger) config = config_handler.tenant_config(tenant) self.solr_service_url = config.get( 'solr_service_url', 'http://localhost:8983/solr/gdi/select') self.word_split_re = re.compile( config.get('word_split_re', r'[\s,.:;"]+')) self.default_search_limit = config.get('search_result_limit', 50) self.resources = self.load_resources(config) self.permissions_handler = PermissionsReader(tenant, logger) def search(self, identity, searchtext, filter, limit): search_permissions = self.search_permissions(identity) (filterword, tokens) = self.tokenize(searchtext) filter_ids = filter if not filter: # use all permitted facets if filter is empty filter_ids = search_permissions.keys() if not limit: limit = self.default_search_limit response = self.query(tokens, filterword, filter_ids, limit, search_permissions) # Return Solr error response if type(response) is tuple: return response self.logger.debug(json.dumps(response, indent=2)) permitted_dataproducts = self.dataproduct_permissions(identity) results = [] num_solr_results_dp = 0 for doc in response['response']['docs']: if doc['facet'] == 'foreground' or doc[ 'facet'] == 'background' or doc['facet'] == 'dataproduct': num_solr_results_dp += 1 result = self.layer_result(doc, permitted_dataproducts) if result is not None: results.append(result) else: results.append( self.feature_result(doc, filterword, search_permissions)) result_counts = self.result_counts(response, filterword, num_solr_results_dp, search_permissions) return {'results': results, 'result_counts': result_counts} def query(self, tokens, filterword, filter_ids, limit, search_permissions): # https://lucene.apache.org/solr/guide/8_1/common-query-parameters.html q = self.query_str(tokens) fq = self.filter_query_str(filterword, filter_ids, search_permissions) response = requests.get( self.solr_service_url, params="omitHeader=true&facet=true&facet.field=facet&rows={}" "&sort=score desc,sort desc&{}&{}".format(limit, q, fq), timeout=10) self.logger.debug("Sending Solr query %s" % response.url) self.logger.info("Search words: %s", ','.join(tokens)) if response.status_code == 200: return json.loads(response.content) else: self.logger.warning("Solr Error:\n\n%s" % response.text) return (response.text, response.status_code) def tokenize(self, searchtext): match = FILTERWORD_RE.match(searchtext) if match: st = searchtext[match.span()[1]:] return (match.group(1), self.split_words(st)) else: return (None, self.split_words(searchtext)) def query_str(self, tokens): lines = map(lambda p: self.join_word_parts(p, tokens), QUERY_PARTS) query = ' OR '.join(lines) return 'q=%s' % query def filter_query_str(self, filterword, filter_ids, search_permissions): if filterword: facets = [self.filterword_to_facet(filterword, search_permissions)] elif '*' in search_permissions: # all allowed, facet = filterword facets = filter_ids else: # Remove facets without permissions facets = list( filter(lambda f: search_permissions.get(f), filter_ids)) if len(facets) != len(filter_ids): self.logger.info("Removed filter ids with missing permissions") self.logger.info("Passed filter ids: %s" % filter_ids) self.logger.info("Permitted filter ids: %s" % facets) # Avoid empty fq if len(facets) == 0: facets = ['_'] facets = map(lambda f: 'facet:%s' % f, facets) facet_query = ' OR '.join(facets) fq = 'fq=tenant:%s' % self.tenant if facet_query: fq += ' AND (%s)' % facet_query return fq def filterword_to_facet(self, filterword, search_permissions): if '*' in search_permissions: # all allowed, facet = filterword return filterword for facet, entries in search_permissions.items(): # filterword lookup table should be cached for entry in entries: if self.check_filterword(filterword, entry): return facet self.logger.info("Filterword not found: %s" % filterword) return '_' def layer_result(self, doc, permitted_dataproducts): id = json.loads(doc['id']) dataproduct_id = id[1] # skip layer without permissions if dataproduct_id not in permitted_dataproducts: self.logger.debug("Skipping layer result with " "missing permission: %s" % dataproduct_id) return None layer = { 'display': doc['display'], 'type': id[0], 'stacktype': doc['facet'], 'dataproduct_id': dataproduct_id, 'dset_info': doc['dset_info'] } if 'dset_children' in doc: sublayers = [] children = json.loads(doc['dset_children']) for child in children: child_ident = child['ident'] if child_ident in permitted_dataproducts: sublayers.append({ 'display': child['display'], 'type': child['subclass'], 'dataproduct_id': child_ident, 'dset_info': child['dset_info'] }) else: self.logger.debug("Skipping child layer with " "missing permission: %s" % child_ident) layer['sublayers'] = sublayers return {'dataproduct': layer} def feature_result(self, doc, filterword, search_permissions): id = json.loads(doc['id']) idfield_meta = json.loads(doc['idfield_meta']) idfield_str = idfield_meta[1].split(':')[1] == 'y' bbox = json.loads(doc['bbox']) if 'bbox' in doc else None facet = id[0] # Solr index uses dataset id as facet feature_id = id[1] if not idfield_str: try: feature_id = int(id[1]) except Exception as e: self.logger.error("Error converting feature_id to int: %s" % e) idfield_str = True if '*' in search_permissions: return self._feature_rec(doc, idfield_meta, facet, feature_id, idfield_str, bbox) # Return only permitted facets for entry in search_permissions.get(facet, []): if self.check_filterword(filterword, entry): return self._feature_rec(doc, idfield_meta, facet, feature_id, idfield_str, bbox) return {} def _feature_rec(self, doc, idfield_meta, facet, feature_id, idfield_str, bbox): id_field_name = idfield_meta[0] feature = { 'display': doc['display'], 'dataproduct_id': facet, 'feature_id': feature_id, 'id_field_name': id_field_name, 'id_field_type': idfield_str, 'bbox': bbox } return {'feature': feature} def result_counts(self, response, filterword, num_solr_results_dp, search_permissions): result_counts = [] facet_counts = response['facet_counts']['facet_fields']['facet'] for i in range(0, len(facet_counts), 2): count = facet_counts[i + 1] if count > 0: facet = facet_counts[i] if facet == 'foreground' or facet == 'background' or facet == 'dataproduct': # dataproduct Count from Solr does not consider permissions if count <= num_solr_results_dp: # Don't return count if all results already included continue count = None if '*' in search_permissions: result_counts.append({ 'dataproduct_id': facet, 'filterword': facet, 'count': count }) else: # Return multiple results if facet is used for multiple dataproducts for entry in search_permissions.get(facet, []): if self.check_filterword(filterword, entry): result_counts.append({ 'dataproduct_id': facet, # rename dataproduct_id to facet! 'filterword': entry['filter_word'], 'count': count }) return result_counts def check_filterword(self, filterword, entry): return not filterword or (entry['filter_word'].lower() == filterword.lower()) def load_resources(self, config): """Load service resources from config. :param RuntimeConfig config: Config handler """ # collect service resources (group by facet name) facets = {} for facet in config.resources().get('facets', []): if facet['name'] not in facets: facets[facet['name']] = [] facets[facet['name']].append(facet) return {'facets': facets} def search_permissions(self, identity): """Return permitted search facets. :param str identity: User identity """ # get permitted facets permitted_facets = self.permissions_handler.resource_permissions( 'solr_facets', identity) # unique set permitted_facets = set(permitted_facets) # filter by permissions facets = {} for facet in self.resources['facets']: if facet in permitted_facets: facets[facet] = self.resources['facets'][facet] return facets def dataproduct_permissions(self, identity): """Return permitted dataproducts. :param str identity: User identity """ # get permitted dataproducts permitted_dataproducts = self.permissions_handler.resource_permissions( 'dataproducts', identity) # return unique sorted dataproducts return sorted(list(set(permitted_dataproducts))) def split_words(self, searchtext): return list(filter(None, re.split(self.word_split_re, searchtext))) def join_word_parts(self, part, tokens): parts = map(lambda t: part.format(t), tokens) return '(%s)' % ' AND '.join(parts)
class FeatureInfoService(): """FeatureInfoService class Query layers at a geographic position using different layer info providers. """ def __init__(self, tenant, logger): """Constructor :param str tenant: Tenant ID :param Logger logger: Application logger """ self.tenant = tenant self.logger = logger config_handler = RuntimeConfig("featureInfo", logger) config = config_handler.tenant_config(tenant) if config.get('default_info_template'): self.default_info_template = config.get('default_info_template') elif config.get('default_info_template_base64'): self.default_info_template = self.b64decode( config.get('default_info_template_base64'), default_info_template, "default info template" ) else: self.default_info_template = default_info_template self.default_wms_url = config.get( 'default_qgis_server_url', 'http://localhost:8001/ows/') self.data_service_url = config.get( 'data_service_url', '/api/v1/data/').rstrip('/') + '/' self.resources = self.load_resources(config) self.permissions_handler = PermissionsReader(tenant, logger) self.db_engine = DatabaseEngine() def query(self, identity, service_name, layers, params): """Query layers and return info result as XML. :param str identity: User identity :param str service_name: Service name :param list(str): List of query layer names :param obj params: FeatureInfo service params """ if not self.wms_permitted(service_name, identity): # map unknown or not permitted return self.service_exception( 'MapNotDefined', 'Map "%s" does not exist or is not permitted' % service_name ) # calculate query coordinates and resolutions try: bbox = list(map(float, params["bbox"].split(","))) x = 0.5 * (bbox[0] + bbox[2]) y = 0.5 * (bbox[1] + bbox[3]) xres = (bbox[2] - bbox[0]) / params['width'] yres = (bbox[3] - bbox[1]) / params['height'] except Exception as e: x = 0 y = 0 xres = 0 yres = 0 params['resolution'] = max(xres, yres) crs = params['crs'] # filter layers by permissions and replace group layers # with permitted sublayers permitted_layers = self.permitted_layers(service_name, identity) group_layers = \ self.resources['wms_services'][service_name]['group_layers'] expanded_layers = self.expand_group_layers( layers, group_layers, permitted_layers ) # collect layer infos layer_infos = [] for layer in expanded_layers: info = self.get_layer_info( identity, service_name, layer, x, y, crs, params ) if info is not None: layer_infos.append(info) info_xml = ( "<GetFeatureInfoResponse>%s</GetFeatureInfoResponse>" % ''.join(layer_infos) ) return info_xml def service_exception(self, code, message): """Create ServiceExceptionReport XML :param str code: ServiceException code :param str message: ServiceException text """ return ( '<ServiceExceptionReport version="1.3.0">\n' ' <ServiceException code="%s">%s</ServiceException>\n' '</ServiceExceptionReport>' % (code, message) ) def expand_group_layers(self, requested_layers, group_layers, permitted_layers): """Recursively filter layers by permissions and replace group layers with permitted sublayers and return resulting layer list. :param list(str) requested_layers: List of requested layer names :param obj group_layers: Lookup for group layers with sublayers :param list(str) permitted_layers: List of permitted layer names """ expanded_layers = [] for layer in requested_layers: if layer in permitted_layers: if layer in group_layers: # expand sublayers sublayers = [] for sublayer in group_layers.get(layer): if sublayer in permitted_layers: sublayers.append(sublayer) expanded_layers += self.expand_group_layers( sublayers, group_layers, permitted_layers ) else: # leaf layer expanded_layers.append(layer) return expanded_layers def get_layer_info(self, identity, service_name, layer, x, y, crs, params): """Get info for a layer rendered as info template. :param str identity: User identity :param str service_name: Service name :param str layer: Layer name :param float x: X coordinate of query :param float y: Y coordinate of query :param str crs: CRS of query coordinates :param obj params: FeatureInfo service params """ # get layer config config = self.resources['wms_services'][service_name]['layers'][layer] layer_title = config.get('title') info_template = config.get('info_template') attributes = config.get('attributes', []) attribute_aliases = config.get('attribute_aliases', {}) attribute_formats = config.get('attribute_formats', {}) json_attribute_aliases = config.get('json_attribute_aliases', {}) display_field = config.get('display_field') feature_report = config.get('feature_report') parent_facade = config.get('parent_facade') # get layer permissions layer_permissions = self.layer_permissions( service_name, layer, identity ) # filter by permissions if not layer_permissions['info_template']: info_template = None permitted_attributes = [ attr for attr in attributes if attr in layer_permissions['attributes'] ] if info_template and not info_template.get('template'): # use any Base64 encoded info template if info_template.get('template_base64'): info_template['template'] = self.b64decode( info_template.get('template_base64'), None, "info template of layer '%s'" % layer ) if info_template is None: self.logger.info("No info template for layer '%s'" % layer) # fallback to WMS GetFeatureInfo with default info template info_template = { 'template': self.default_info_template, 'type': 'wms' } elif not info_template.get('template'): self.logger.info( "Empty template in info template for layer '%s'" % layer ) # use default info template if not specified in config info_template['template'] = self.default_info_template info = None error_msg = None info_type = info_template.get('type') if info_type == 'wms': # WMS GetFeatureInfo forward_auth_headers = False if info_template.get('wms_url'): # use layer specific WMS wms_url = info_template.get('wms_url') else: # use default WMS wms_url = urljoin(self.default_wms_url, service_name) forward_auth_headers = True info = wms_layer_info( layer, x, y, crs, params, identity, wms_url, permitted_attributes, attribute_aliases, attribute_formats, forward_auth_headers, self.logger ) elif info_type == 'sql': # DB query database = info_template.get('db_url') sql = info_template.get('sql') if not sql: # use any Base64 encoded info SQL sql = self.b64decode( info_template.get('sql_base64'), "", "info SQL of layer '%s'" % layer ) info = sql_layer_info( layer, x, y, crs, params, identity, self.db_engine, database, sql, self.logger ) elif info_type == 'module': # custom module try: # import custom layer info method module_name = info_template.get('module') custom_module = import_module( 'info_modules.custom.%s' % module_name ) layer_info = getattr(custom_module, 'layer_info') # call layer info info = layer_info(layer, x, y, crs, params, identity) except ImportError as e: error_msg = "ImportError for layer '%s': %s" % (layer, e) except AttributeError as e: error_msg = "AttributeError for layer '%s': %s" % (layer, e) except Exception as e: error_msg = ( "Exception in custom info module '%s' " "for layer '%s':\n%s" % (module_name, layer, traceback.format_exc()) ) if error_msg is not None: self.logger.error(error_msg) info = {'error': error_msg} if info is None or not isinstance(info, dict): # info result failed or not a dict return None if info.get('error'): # render layer template with error message error_html = ( '<span class="info_error" style="color: red">%s</span>' % info.get('error') ) features = [{ 'html_content': self.html_content(error_html) }] return layer_template.render( layer_name=layer, layer_title=layer_title, features=features, parent_facade=parent_facade ) if not info.get('features'): # info result is empty return layer_template.render( layer_name=layer, layer_title=layer_title, parent_facade=parent_facade ) template = info_template.get('template') features = [] for feature in info.get('features'): # create info feature with attributes info_feature = InfoFeature() for attr in feature.get('attributes', []): name = attr.get('name') json_aliases = json_attribute_aliases.get(name) value = self.parse_value(attr.get('value'), json_aliases) if isinstance(value, str) and value.startswith("attachment://"): value = "attachment://" + self.data_service_url + "/" + service_name + "." + layer + "/attachment?file=" + value[13:] alias = attribute_aliases.get(name, name) info_feature.add(name, value, alias, json_aliases) fid = feature.get('id') bbox = feature.get('bbox') geometry = feature.get('geometry') info_html = None try: # render feature template feature_template = Template(template) info_html = feature_template.render( feature=info_feature, fid=fid, bbox=bbox, geometry=geometry, layer=layer, x=x, y=y, crs=crs, render_value=self.render_value ) except TemplateSyntaxError as e: error_msg = ( "TemplateSyntaxError on line %d: %s" % (e.lineno, e) ) except TemplateError as e: error_msg = "TemplateError: %s" % e if error_msg is not None: self.logger.error(error_msg) info_html = ( '<span class="info_error" style="color: red">%s</span>' % error_msg ) features.append({ 'fid': fid, 'html_content': self.html_content(info_html), 'bbox': bbox, 'wkt_geom': geometry, 'attributes': info_feature._attributes }) # render layer template return layer_template.render( layer_name=layer, layer_title=layer_title, crs=crs, features=features, display_field=display_field, feature_report=feature_report, parent_facade=parent_facade ) def parse_value(self, value, json_aliases): """Parse info result value and convert to dict or list if JSON. :param obj value: Info value :param OrderedDict json_aliases: JSON attributes config """ if isinstance(value, str): try: if value.startswith('{') or value.startswith('['): # parse JSON with original order of keys json_value = json.loads( value, object_pairs_hook=OrderedDict ) if json_aliases and isinstance(json_value, list): # JSON aliases present and JSON value is a list value = [] for json_item in json_value: # reorder item keys according to JSON aliases item = OrderedDict() for key in json_aliases: if key in json_item: item[key] = json_item[key] # add any additional keys not in JSON aliases for key in json_item: if key not in json_aliases: item[key] = json_item[key] value.append(item) else: # JSON value is a dict or no JSON aliases present value = json_value except Exception as e: self.logger.error( "Could not parse value as JSON: '%s'\n%s" % (value, e) ) return value def render_value(self, value, htmlEscape=True): """Escape HTML special characters if requested, and detect special value formats in info result values and reformat them. :param obj value: Info value :param bool htmlEscape: Whether to HTML escape the value """ if isinstance(value, str): # If value is already html (i.e. begins with a valid HTML tag), return it as is if value.startswith("<") and value.find(">") != -1 and re.match("<(\"[^\"]*\"|'[^']*'|[^'\">])*>", value[0:value.find(">") + 1]): return value if htmlEscape: value = html.escape(value) rules = [( # HTML links r'^(https?:\/\/.*)$', lambda m: m.expand(r'<a href="\1" target="_blank">Link</a>') ),( # Attachments r'^attachment://(.+)/([^/]+)$', lambda m: m.expand(r'<a href="\1/\2" target="_blank"><img src="\1/\2" alt="\2" style="width: 100%" /></a>') )] for rule in rules: match = re.match(rule[0], value) if match: value = rule[1](match) break return value def html_content(self, info_html): """Return <HtmlContent> tag with escaped HTML. :param str info_html: Info HTML """ doc = Document() el = doc.createElement('HtmlContent') el.setAttribute('inline', '1') text = Text() text.data = info_html el.appendChild(text) return el.toxml() def load_resources(self, config): """Load service resources from config. :param RuntimeConfig config: Config handler """ wms_services = {} # collect service resources for wms in config.resources().get('wms_services', []): # collect map layers layers = {} group_layers = {} self.collect_layers(wms['root_layer'], layers, group_layers) wms_services[wms['name']] = { 'layers': layers, 'group_layers': group_layers } return { 'wms_services': wms_services } def collect_layers(self, layer, layers, group_layers, parent_group=None): """Recursively collect layer info for layer subtree from config. :param obj layer: Layer or group layer :param obj layers: Partial lookup for layer configs :param obj group_layers: Partial lookup for group layer configs :param str parent_group: Name of visible parent group if sublayers are hidden """ if layer.get('layers'): # group layer if layer.get('hide_sublayers', False) and parent_group is None: parent_group = layer['name'] # collect sub layers sublayers = [] for sublayer in layer['layers']: sublayers.append(sublayer['name']) # recursively collect sub layer self.collect_layers( sublayer, layers, group_layers, parent_group ) group_layers[layer['name']] = sublayers else: # layer # collect attributes config attributes = [] attribute_aliases = {} attribute_formats = {} json_aliases = {} for attr in layer.get('attributes', []): attributes.append(attr['name']) if attr.get('alias'): attribute_aliases[attr['name']] = attr['alias'] if attr.get('format'): attribute_formats[attr['name']] = attr['format'] elif attr.get('format_base64'): attr_format = self.b64decode( attr.get('format_base64'), None, "format of attribute '%s' in layer '%s'" % (attr['name'], layer['name']) ) if attr_format: attribute_formats[attr['name']] = attr_format if attr.get('json_attribute_aliases'): # NOTE: keep order of JSON aliases from config json_attribute_aliases = OrderedDict() for entry in attr['json_attribute_aliases']: json_attribute_aliases[entry['name']] = entry['alias'] json_aliases[attr['name']] = json_attribute_aliases # add layer config config = { 'title': layer.get('title', layer['name']), 'attributes': attributes } if layer.get('info_template'): config['info_template'] = layer.get('info_template') if attribute_aliases: config['attribute_aliases'] = attribute_aliases if attribute_formats: config['attribute_formats'] = attribute_formats if json_aliases: config['json_attribute_aliases'] = json_aliases if layer.get('display_field'): config['display_field'] = layer.get('display_field') if layer.get('feature_report'): config['feature_report'] = layer.get('feature_report') if parent_group: config['parent_facade'] = parent_group layers[layer['name']] = config def wms_permitted(self, service_name, identity): """Return whether WMS is available and permitted. :param str service_name: Service name :param obj identity: User identity """ if self.resources['wms_services'].get(service_name): # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, service_name ) if wms_permissions: return True return False def permitted_layers(self, service_name, identity): """Return permitted layers for a map. :param str service_name: Service name :param obj identity: User identity """ wms_resources = self.resources['wms_services'][service_name].copy() # get available layers available_layers = set( list(wms_resources['layers'].keys()) + list(wms_resources['group_layers'].keys()) ) # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, service_name ) # combine permissions permitted_layers = set() for permission in wms_permissions: # collect available and permitted layers layers = [ layer['name'] for layer in permission['layers'] if layer['name'] in available_layers ] permitted_layers.update(layers) # return sorted layers return sorted(list(permitted_layers)) def layer_permissions(self, service_name, layer, identity): """Return permitted layer attributes and info template. :param str service_name: Service name :param str layer: Layer name :param obj identity: User identity """ # get permissions for WMS wms_permissions = self.permissions_handler.resource_permissions( 'wms_services', identity, service_name ) # combine permissions permitted_attributes = set() info_template_permitted = False for permission in wms_permissions: # find requested layer for l in permission['layers']: if l['name'] == layer: # found matching layer permitted_attributes.update(l.get('attributes', [])) info_template_permitted |= l.get('info_template', False) break return { 'attributes': sorted(list(permitted_attributes)), 'info_template': info_template_permitted } def b64decode(self, base64_value, default, description=""): """Return decoded Base64 encoded value or default on error. :param str base64_value: Base64 encoded value :param str default: Default value returned on decoding error :param str description: Description included in error message """ value = default try: value = base64.b64decode(base64_value).decode('utf-8') except Exception as e: self.logger.error( "Could not decode Base64 encoded value for %s:" "\n%s\n%s" % (description, e, base64_value) ) value = default return value