Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "pymviewer"]
path = pymviewer
url = https://github.com/jdev-org/pymviewer.git
1 change: 1 addition & 0 deletions pymviewer
Submodule pymviewer added at b3c043
116 changes: 91 additions & 25 deletions src/routes/qgis.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
import logging
from pathlib import Path
from uuid import uuid4
from tempfile import TemporaryDirectory
Expand Down Expand Up @@ -35,10 +36,14 @@
ET.register_namespace("rdf", RDF_NAMESPACE)
ET.register_namespace("dc", DC_NAMESPACE)

logger = logging.getLogger(__name__)


def _configured_qgis_projects_url() -> str:
"""Return the configured QGIS projects base URL with a trailing slash."""
internal_base_url = (current_app.config.get("QGIS_SERVER_INTERNAL_URL") or "").strip()
internal_base_url = (
current_app.config.get("QGIS_SERVER_INTERNAL_URL") or ""
).strip()
if internal_base_url:
return internal_base_url.rstrip("/") + "/"

Expand All @@ -60,7 +65,9 @@ def _public_qgis_projects_url() -> str:

def _fetchable_qgis_capabilities_url(capabilities_url: str) -> str:
"""Return a backend-reachable capabilities URL while preserving the public URL."""
internal_base_url = (current_app.config.get("QGIS_SERVER_INTERNAL_URL") or "").strip()
internal_base_url = (
current_app.config.get("QGIS_SERVER_INTERNAL_URL") or ""
).strip()
if not internal_base_url:
return capabilities_url

Expand All @@ -78,7 +85,9 @@ def _fetchable_qgis_capabilities_url(capabilities_url: str) -> str:
def _is_configured_qgis_url(url: str) -> bool:
"""Return ``True`` when the URL targets the configured QGIS projects base."""
configured_urls = {_public_qgis_projects_url()}
internal_base_url = (current_app.config.get("QGIS_SERVER_INTERNAL_URL") or "").strip()
internal_base_url = (
current_app.config.get("QGIS_SERVER_INTERNAL_URL") or ""
).strip()
if internal_base_url:
configured_urls.add(internal_base_url.rstrip("/") + "/")
return any(url.startswith(configured_url) for configured_url in configured_urls)
Expand Down Expand Up @@ -189,15 +198,19 @@ def _find_direct_child(element: ET.Element, child_name: str) -> ET.Element | Non

def _find_direct_children(element: ET.Element, child_name: str) -> list[ET.Element]:
"""Return all direct children matching the provided local name."""
return [child for child in list(element) if _xml_local_name(child.tag) == child_name]
return [
child for child in list(element) if _xml_local_name(child.tag) == child_name
]


def _format_bbox_value(value: float) -> str:
"""Serialize a bbox numeric value without trailing zeros when possible."""
return f"{value:.12g}"


def _parse_bbox_attributes(bbox_node: ET.Element) -> tuple[float, float, float, float] | None:
def _parse_bbox_attributes(
bbox_node: ET.Element,
) -> tuple[float, float, float, float] | None:
"""Return bbox coordinates from a WMS ``BoundingBox``-like XML node."""
try:
return (
Expand All @@ -221,7 +234,9 @@ def _root_layer_bbox_from_capabilities(

capability_node = _find_direct_child(capabilities_root, "Capability")
root_layer_node = (
_find_direct_child(capability_node, "Layer") if capability_node is not None else None
_find_direct_child(capability_node, "Layer")
if capability_node is not None
else None
)
if root_layer_node is None:
return None
Expand All @@ -247,21 +262,35 @@ def _root_layer_bbox_from_capabilities(
matching_bbox_node = bounding_boxes[0]

bbox_values = (
_parse_bbox_attributes(matching_bbox_node) if matching_bbox_node is not None else None
_parse_bbox_attributes(matching_bbox_node)
if matching_bbox_node is not None
else None
)
if bbox_values is None and normalized_projection in ("", "EPSG:4326", "CRS:84"):
latlon_bbox_node = _find_direct_child(root_layer_node, "LatLonBoundingBox")
bbox_values = (
_parse_bbox_attributes(latlon_bbox_node) if latlon_bbox_node is not None else None
_parse_bbox_attributes(latlon_bbox_node)
if latlon_bbox_node is not None
else None
)

if bbox_values is None:
geographic_bbox_node = _find_direct_child(root_layer_node, "EX_GeographicBoundingBox")
geographic_bbox_node = _find_direct_child(
root_layer_node, "EX_GeographicBoundingBox"
)
if geographic_bbox_node is not None:
west_node = _find_direct_child(geographic_bbox_node, "westBoundLongitude")
south_node = _find_direct_child(geographic_bbox_node, "southBoundLatitude")
east_node = _find_direct_child(geographic_bbox_node, "eastBoundLongitude")
north_node = _find_direct_child(geographic_bbox_node, "northBoundLatitude")
west_node = _find_direct_child(
geographic_bbox_node, "westBoundLongitude"
)
south_node = _find_direct_child(
geographic_bbox_node, "southBoundLatitude"
)
east_node = _find_direct_child(
geographic_bbox_node, "eastBoundLongitude"
)
north_node = _find_direct_child(
geographic_bbox_node, "northBoundLatitude"
)
try:
bbox_values = (
float(west_node.text),
Expand Down Expand Up @@ -294,8 +323,7 @@ def _apply_root_layer_bbox_to_mviewer_xml(

minx, miny, maxx, maxy = [float(value) for value in bbox.split(",")]
center = ",".join(
_format_bbox_value(value)
for value in ((minx + maxx) / 2, (miny + maxy) / 2)
_format_bbox_value(value) for value in ((minx + maxx) / 2, (miny + maxy) / 2)
)

mapoptions_node.set("extent", bbox)
Expand All @@ -309,14 +337,24 @@ def _store_uploaded_qgis_project() -> dict:
if not qgs_dir:
raise BadRequest("Missing QGS folder configuration")

logger.info(
"QGIS upload request received: content_type=%s files=%s form=%s",
request.content_type,
list(request.files.keys()),
list(request.form.keys()),
)
uploaded_file = request.files.get("file")
if not uploaded_file or not uploaded_file.filename:
logger.info("QGIS upload rejected: missing file field in multipart payload")
raise BadRequest("Missing QGS file")

filename = secure_filename(uploaded_file.filename)
logger.info("QGIS upload received file=%s target_dir=%s", filename, qgs_dir)
lower_filename = filename.lower()
if not lower_filename.endswith((".qgs", ".zip", ".qgz")):
raise BadRequest("Only .qgs files, .zip archives or .qgz archives are supported")
raise BadRequest(
"Only .qgs files, .zip archives or .qgz archives are supported"
)

if not path.exists(qgs_dir):
mkdir(qgs_dir)
Expand Down Expand Up @@ -344,13 +382,15 @@ def _ensure_config_metadata(xml_content: str, project_name: str) -> str:

if not xml_root.get("mviewerversion") and app_conf.get("mviewer_version"):
xml_root.set("mviewerversion", str(app_conf["mviewer_version"]))
if not xml_root.get("mviewerstudioversion") and app_conf.get("mviewerstudio_version"):
if not xml_root.get("mviewerstudioversion") and app_conf.get(
"mviewerstudio_version"
):
xml_root.set("mviewerstudioversion", str(app_conf["mviewerstudio_version"]))

application_node = xml_root.find("application")
app_title = (application_node.get("title") if application_node is not None else "") or (
project_name or "Projet QGIS"
)
app_title = (
application_node.get("title") if application_node is not None else ""
) or (project_name or "Projet QGIS")
creator = current_user.username if current_user else "anonymous"
publisher = (
current_user.normalize_name
Expand Down Expand Up @@ -426,7 +466,11 @@ def _absolute_config_url(config_path: str) -> str:
relative_path = "/".join(
part for part in [conf_path, normalize_url_part(config_path)] if part
)
return f"{mviewer_instance}/{relative_path}" if mviewer_instance else f"/{relative_path}"
return (
f"{mviewer_instance}/{relative_path}"
if mviewer_instance
else f"/{relative_path}"
)


def _studio_open_config_url(config_url: str) -> str:
Expand Down Expand Up @@ -495,7 +539,9 @@ def list_stored_qgs_configs() -> ResponseReturnValue:
for root, _, files in walk(qgs_dir):
for filename in files:
if filename.lower().endswith(".qgs"):
qgs_files.append(_qgs_project_payload(qgs_dir, path.join(root, filename)))
qgs_files.append(
_qgs_project_payload(qgs_dir, path.join(root, filename))
)

qgs_files.sort(key=lambda item: item["path"].lower())
return jsonify(qgs_files)
Expand All @@ -517,14 +563,24 @@ def import_qgis_project() -> ResponseReturnValue:
if not qgs_dir:
raise BadRequest("Missing QGS folder configuration")

logger.info(
"QGIS import request received: content_type=%s files=%s form=%s",
request.content_type,
list(request.files.keys()),
list(request.form.keys()),
)
uploaded_file = request.files.get("file")
if not uploaded_file or not uploaded_file.filename:
logger.info("QGIS import rejected: missing file field in multipart payload")
raise BadRequest("Missing QGS file")

filename = secure_filename(uploaded_file.filename)
logger.info("QGIS import received file=%s target_dir=%s", filename, qgs_dir)
lower_filename = filename.lower()
if not lower_filename.endswith((".qgs", ".zip", ".qgz")):
raise BadRequest("Only .qgs files, .zip archives or .qgz archives are supported")
raise BadRequest(
"Only .qgs files, .zip archives or .qgz archives are supported"
)

if not path.exists(qgs_dir):
mkdir(qgs_dir)
Expand All @@ -538,6 +594,12 @@ def import_qgis_project() -> ResponseReturnValue:
project_payload, overwritten = _store_qgs_file(uploaded_file, qgs_dir, filename)

capabilities_url = _project_capabilities_url(project_payload["projectName"])
logger.info(
"QGIS import resolved project=%s capabilities_url=%s overwritten=%s",
project_payload["projectName"],
capabilities_url,
overwritten,
)
xml_content = _mviewer_xml_from_capabilities_url(capabilities_url)
response = current_app.response_class(xml_content, mimetype="application/xml")
response.headers["X-Qgis-Project-Name"] = project_payload["projectName"]
Expand Down Expand Up @@ -579,12 +641,16 @@ def create_and_open_qgis_project_config() -> ResponseReturnValue:


@basic_store.route("/api/app/qgis/projects/<project_name>/open", methods=["POST"])
def create_and_open_stored_qgis_project_config(project_name: str) -> ResponseReturnValue:
def create_and_open_stored_qgis_project_config(
project_name: str,
) -> ResponseReturnValue:
"""Create a Studio config from an existing stored QGIS project."""
try:
_stored_qgis_project_directory(project_name)
capabilities_url = _project_capabilities_url(project_name)
config_data, _ = _create_config_from_qgis_capabilities(capabilities_url, project_name)
config_data, _ = _create_config_from_qgis_capabilities(
capabilities_url, project_name
)
config_url = _absolute_config_url(config_data["url"])
studio_url = _studio_open_config_url(config_url)

Expand Down
15 changes: 9 additions & 6 deletions src/routes/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ def _allowed_proxy_origins() -> set[str]:
forwarded_host = request.headers.get("X-Forwarded-Host")
if forwarded_host:
allowed_origins.update(
_normalize_origin(host) for host in forwarded_host.split(",") if host.strip()
_normalize_origin(host)
for host in forwarded_host.split(",")
if host.strip()
)
return allowed_origins

Expand All @@ -70,16 +72,16 @@ def _is_allowed_proxy_origin(url: str) -> bool:
if target_hostname in allowed_hostnames:
return True

return (
target_hostname in _localhost_aliases()
and bool(allowed_hostnames.intersection(_localhost_aliases()))
return target_hostname in _localhost_aliases() and bool(
allowed_hostnames.intersection(_localhost_aliases())
)


def _is_get_capabilities_url(url: str) -> bool:
parsed_url = urlparse(url)
query_params = {
key.lower(): value for key, value in parse_qsl(parsed_url.query, keep_blank_values=True)
key.lower(): value
for key, value in parse_qsl(parsed_url.query, keep_blank_values=True)
}
return query_params.get("request", "").lower() == "getcapabilities"

Expand Down Expand Up @@ -214,7 +216,8 @@ def _extract_qgs_zip(
extracted_projects.append(path.join(root, extracted_file))

extracted_payloads = [
_qgs_project_payload(qgs_dir, absolute_file) for absolute_file in extracted_projects
_qgs_project_payload(qgs_dir, absolute_file)
for absolute_file in extracted_projects
]
extracted_payloads.sort(key=lambda item: item["path"].lower())
return destination_dir, extracted_payloads, overwritten
Expand Down
6 changes: 5 additions & 1 deletion src/routes/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ def get_all_app_versions(id) -> ResponseReturnValue:
if not config:
raise BadRequest("This config doesn't exists !")
config = config[0]
org = current_user.normalize_name if current_user else current_app.config["DEFAULT_ORG"]
org = (
current_user.normalize_name
if current_user
else current_app.config["DEFAULT_ORG"]
)
workspace = path.join(current_app.config["EXPORT_CONF_FOLDER"], org, config["id"])
git = Git_manager(workspace)
return jsonify({"versions": git.get_versions(), "config": config})
Expand Down
Loading
Loading