예제 #1
0
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)
예제 #2
0
    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
예제 #4
0
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
        }
예제 #5
0
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 ""
예제 #6
0
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']
예제 #7
0
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']
예제 #8
0
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)
예제 #9
0
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