diff --git a/src/route.py b/src/route.py index 4dffd38c..c449f79f 100644 --- a/src/route.py +++ b/src/route.py @@ -7,7 +7,6 @@ redirect, render_template_string, send_from_directory, - url_for, ) from .utils.login_utils import current_user from .utils.config_utils import ( @@ -18,7 +17,14 @@ replace_templates_url, read_xml_file_content, ) -from .utils.commons import clean_preview, init_preview, create_zip, make_archive +from .utils.commons import clean_preview, init_preview, create_zip +from .utils.route_utils import ( + validate_xml_creator, + get_xml_identifier, + get_existing_config_or_404, + authorize_config_mutation, + fetch_remote_xml, +) import hashlib, uuid from .utils.register_utils import from_xml_path from os import path, mkdir, remove @@ -42,6 +48,11 @@ @basic_store.record_once def basic_store_init(state: BlueprintSetupState): + """ + Create backend storage directories required by the application at startup. + + :param state: blueprint setup state provided by Flask + """ p = state.app.config["EXPORT_CONF_FOLDER"] styles_path = path.join(p, "styles") if not path.exists(p): @@ -104,6 +115,25 @@ def user() -> Response: return jsonify(current_user.as_dict()) +@basic_store.route("/api/app/load", methods=["GET", "POST"]) +def load_config() -> Response: + """ + Load a configuration XML through the backend and enforce dc:creator access + control before returning it to the client. + """ + xml_text = "" + + if request.method == "GET": + xml_text = fetch_remote_xml(request.args.get("url")) + else: + if not request.data: + raise BadRequest("No XML found in the request body !") + xml_text = request.data.decode("utf-8") + + xml_text = validate_xml_creator(xml_text) + return Response(xml_text, mimetype="application/xml") + + @basic_store.route("/api/app", methods=["POST"]) def create_config() -> Response: """ @@ -139,6 +169,14 @@ def update_config() -> Response: message = request.args.get("message") if not request.data: raise BadRequest("No XML found in the request body !") + xml_text = request.data.decode("utf-8") + config_id = get_xml_identifier(xml_text) + current_config = current_app.register.read_json(config_id) + if not current_config: + raise BadRequest( + "This config does not exists yet ! Use creation POST request instead." + ) + authorize_config_mutation(current_config[0]) config = Config(request.data, current_app) if not config: raise BadRequest("This XML UUID doesn't exists !") @@ -155,13 +193,6 @@ def update_config() -> Response: # clean preview space if not empty clean_preview(current_app, config_data.url) - current_config = current_app.register.read_json(config_data.id) - - if not current_config: - raise BadRequest( - "This config does not exists yet ! Use creation POST request instead." - ) - current_app.register.update(config_data.as_dict()) return jsonify( { @@ -208,6 +239,7 @@ def publish_config(id, name) -> Response: :param id: configuration UUID """ logger.debug("PUBLISH : %s " % id) + register_config = authorize_config_mutation(get_existing_config_or_404(id)) xml_publish_name = name @@ -247,12 +279,14 @@ def publish_config(id, name) -> Response: # read config if exists if request.method == "POST": + posted_id = get_xml_identifier(request.data.decode("utf-8")) + if posted_id != id: + raise BadRequest("XML identifier does not match route id !") config = Config(request.data, current_app) else: - config = current_app.register.read_json(id) config = from_xml_path( current_app, - path.join(current_app.config["EXPORT_CONF_FOLDER"], config[0]["url"]), + path.join(current_app.config["EXPORT_CONF_FOLDER"], register_config["url"]), ) if not config: raise BadRequest("This config doesn't exists !") @@ -322,32 +356,19 @@ def delete_config_workspace(id=None) -> Response: raise BadRequest("Empty list : no value to delete !") logger.debug("START DELETE CONFIG : %s" % id) - workspace = path.join( - current_app.config["EXPORT_CONF_FOLDER"], current_user.normalize_name, id - ) # update json config = current_app.register.read_json(id) - if not config or not path.exists(workspace): + if not config: logger.debug("DELETE : ERROR - ID OR DIRECTORY NOT EXISTS :") return jsonify({"deleted_files": 0, "success": False}) - config = config[0] - # control if alowed - if ( - current_user.username != "anonymous" - and config["creator"] != current_user.username - ): - logger.debug("DELETE : NOT ALLOWED - ONLY THE OWNER CAN DELETE THIS APP") - return MethodNotAllowed("Not allowed !") - # control if org is default org - if ( - current_user.username == "anonymous" - and config["publisher"] != current_app.config["DEFAULT_ORG"] - ): - logger.debug( - "DELETE : NOT ALLOWED FOR THIS ANONYMOUS USER - ORG IS NOT DEFAULT" - ) - return MethodNotAllowed("Not allowed !") + config = authorize_config_mutation(config[0]) + workspace = path.join( + current_app.config["EXPORT_CONF_FOLDER"], config["publisher"], id + ) + if not path.exists(workspace): + logger.debug("DELETE : ERROR - ID OR DIRECTORY NOT EXISTS :") + return jsonify({"deleted_files": 0, "success": False}) # control org and creator not only org - to delete the correct publish file map_relation = False if "relation" in config and config["relation"]: @@ -413,7 +434,7 @@ def switch_app_version(id, version="1") -> Response: if not config: raise BadRequest("This config doesn't exists !") - config = config[0] + config = authorize_config_mutation(config[0]) workspace = path.join( current_app.config["EXPORT_CONF_FOLDER"], current_user.normalize_name, @@ -557,11 +578,12 @@ def delete_app_versions(id) -> Response: if not post_data["versions"]: raise BadRequest("Empty list - Nothing to delete !") - config = current_app.register.read_json(id) - if not config: - raise BadRequest("This config doesn't exists !") - config = config[0] - workspace = path.join(current_app.config["EXPORT_CONF_FOLDER"], config["id"]) + config = authorize_config_mutation(get_existing_config_or_404(id)) + workspace = path.join( + current_app.config["EXPORT_CONF_FOLDER"], + current_user.normalize_name, + config["id"], + ) git = Git_manager(workspace) for version in post_data["versions"]: @@ -577,10 +599,7 @@ def create_app_version(id) -> Response: Create a tag for a given UUID application. :param id: app UUID """ - config = current_app.register.read_json(id) - if not config: - raise BadRequest("This config doesn't exists !") - config = config[0] + config = authorize_config_mutation(get_existing_config_or_404(id)) workspace = path.join( current_app.config["EXPORT_CONF_FOLDER"], current_user.normalize_name, @@ -617,6 +636,12 @@ def store_style() -> Response: @basic_store.route("/api/app//template/", methods=["POST"]) def add_layer_template(id, file_name) -> Response: + """ + Store a layer template file in the current draft workspace. + + :param id: application UUID + :param file_name: template file name without extension + """ template = request.data.decode("utf-8") config = current_app.register.read_json(id) if not config: @@ -651,10 +676,13 @@ def add_layer_template(id, file_name) -> Response: @basic_store.route("/api/app//template/", methods=["DELETE"]) def delete_layer_template(id, id_layer) -> Response: - config = current_app.register.read_json(id) - if not config: - raise BadRequest("This config doesn't exists !") - config = config[0] + """ + Remove the template reference from a layer and delete the draft file. + + :param id: application UUID + :param id_layer: layer identifier owning the template + """ + config = authorize_config_mutation(get_existing_config_or_404(id)) xml_path = path.join(current_app.config["EXPORT_CONF_FOLDER"], config["url"]) # read XML and remove template parser = read_xml_file_content(xml_path) diff --git a/src/static/js/mviewerstudio.js b/src/static/js/mviewerstudio.js index 7e6432ae..e98039b9 100644 --- a/src/static/js/mviewerstudio.js +++ b/src/static/js/mviewerstudio.js @@ -133,13 +133,15 @@ $(document).ready(function () { } } - if (API.xml) { - loadApplicationParametersFromRemoteFile(API.xml); - } else if (API.wmc) { - loadApplicationParametersFromWMC(API.wmc); - } else { - newConfiguration(); - } + Promise.resolve(getUser()).finally(() => { + if (API.xml) { + loadApplicationParametersFromRemoteFile(API.xml); + } else if (API.wmc) { + loadApplicationParametersFromWMC(API.wmc); + } else { + newConfiguration(); + } + }); updateProviderSearchButtonState(); @@ -147,9 +149,6 @@ $(document).ready(function () { if (_conf.default_params && _conf.default_params.layer) { mv.setDefaultLayerProperties(_conf.default_params.layer); } - - // Get user info - getUser(); }) .catch((err) => { console.log(err); @@ -196,7 +195,7 @@ var config; const getUser = () => { if (!_conf.user_info) return; - fetch(_conf.user_info, { + return fetch(_conf.user_info, { method: "GET", headers: { "Content-Type": "application/json", @@ -208,6 +207,10 @@ const getUser = () => { var userGroupSlugName = ""; var selectGroupPopup = false; if (data) { + mv.updateUserInfo({ + userName: data.user_name, + name: `${data.first_name} ${data.last_name}`, + }); if (data.organisation && data.organisation.legal_name) { userGroupFullName = data.organisation.legal_name; } else if (data && data.user_groups) { @@ -226,8 +229,6 @@ const getUser = () => { }); } else { mv.updateUserInfo({ - userName: data.user_name, - name: `${data.first_name} ${data.last_name}`, groupSlugName: userGroupSlugName || data.normalize_name, groupFullName: userGroupFullName, }); @@ -1328,19 +1329,42 @@ var loadApplicationParametersFromFile = function () { var reader = new FileReader(); reader.readAsText(file, "UTF-8"); reader.onload = function (evt) { - var xml = $.parseXML(evt.target.result); - let idApp = xml.getElementsByTagName("dc:identifier")[0]?.innerHTML; - if (!idApp) { - return mv.parseApplication(xml); - } - // control if ID already exists in studio register - mv.appExists(idApp, (r) => mv.parseApplication(xml, r.exists)); + fetch(`${_conf.api}/load`, { + method: "POST", + headers: { + "Content-Type": "text/xml", + }, + body: evt.target.result, + }) + .then((r) => (r.ok ? r.text() : Promise.reject(r))) + .then((xmlAsString) => $.parseXML(xmlAsString)) + .then((xml) => { + let idApp = xml.getElementsByTagName("dc:identifier")[0]?.innerHTML; + if (!idApp) { + mv.parseApplication(xml); + showStudio(); + return; + } + // control if ID already exists in studio register + mv.appExists(idApp, (r) => { + mv.parseApplication(xml, r.exists); + showStudio(); + }); + }) + .catch((err) => { + if (err?.status === 403) { + return alertCustom(mviewer.tr("msg.creator_mismatch"), "danger"); + } + if (err?.status === 400) { + return alertCustom(mviewer.tr("msg.xml_doc_invalid"), "danger"); + } + return alertCustom(mviewer.tr("msg.file_read_error"), "danger"); + }); }; reader.onerror = function (evt) { //alert(mviewer.tr('msg.file_read_error')); alertCustom(mviewer.tr("msg.file_read_error"), "danger"); }; - showStudio(); } }; @@ -1349,7 +1373,7 @@ var loadApplicationParametersFromRemoteFile = function (url) { mv.sortableInstances.forEach((x) => x.destroy()); mv.sortableInstances = []; const waitRequests = [ - fetch(url, { + fetch(`${_conf.api}/load?url=${encodeURIComponent(url)}`, { method: "GET", cache: "no-cache", }) @@ -1358,10 +1382,7 @@ var loadApplicationParametersFromRemoteFile = function (url) { }) .then((xmlAsString) => new window.DOMParser().parseFromString(xmlAsString, "text/xml") - ) - .catch((r) => { - alertCustom(mviewer.tr("msg.retrieval_req_error"), "danger"); - }), + ), ]; waitRequests.push( fetch(_conf.api) @@ -1371,21 +1392,28 @@ var loadApplicationParametersFromRemoteFile = function (url) { .then((r) => { return r.filter((app) => app.id == config.id); }) - .catch(() => alert(mviewer.tr("msg.retrieval_req_error"), "danger")) ); - Promise.all(waitRequests).then((values) => { - const data = values[0]; - mv.parseApplication(data, true); - if (values[1]) { - const appMeta = values[1][0]; - if (appMeta?.versions) { - config.versions = appMeta.versions; + Promise.all(waitRequests) + .then((values) => { + const data = values[0]; + if (!data) return; + mv.parseApplication(data, true); + if (values[1]) { + const appMeta = values[1][0]; + if (appMeta?.versions) { + config.versions = appMeta.versions; + } } - } - showStudio(); - document.querySelector("#toolsbarStudio-delete").classList.remove("d-none"); - document.querySelector("#layerOptionBtn").classList.remove("d-none"); - }); + showStudio(); + document.querySelector("#toolsbarStudio-delete").classList.remove("d-none"); + document.querySelector("#layerOptionBtn").classList.remove("d-none"); + }) + .catch((err) => { + if (err?.status === 403) { + return alertCustom(mviewer.tr("msg.creator_mismatch"), "danger"); + } + return alertCustom(mviewer.tr("msg.retrieval_req_error"), "danger"); + }); }; var loadApplicationParametersFromWMC = function (url) { diff --git a/src/static/mviewerstudio.i18n.json b/src/static/mviewerstudio.i18n.json index af64c3d7..79b48726 100644 --- a/src/static/mviewerstudio.i18n.json +++ b/src/static/mviewerstudio.i18n.json @@ -360,6 +360,7 @@ "msg.delete_req_error": "Echec de la requête de suppression.\nVeuillez consulter votre administrateur.", "msg.retrieval_req_error": "Echec de la requête de récupération de l'application.\nVeuillez consulter votre administrateur.", "msg.xml_doc_invalid": "Document xml invalide", + "msg.creator_mismatch": "Le chargement est refusé : Vous n'êtes pas le propriétaire de cette carte.", "msg.xml_save": "Document non sauvegardé. \nVeuillez consulter votre administrateur.", "template.add_component": "Ajouter un composant", "template.preview": "Aperçu", @@ -684,6 +685,7 @@ "msg.delete_req_error": "Deletion request failed.\n Please contact your administrator.", "msg.retrieval_req_error": "Application retrieval request failed.\n Please contact your administrator.", "msg.xml_doc_invalid": "XML document invalid", + "msg.creator_mismatch": "Loading denied: this configuration's dc:creator does not match the current user.", "msg.template.wich_component": "Wich component to add ?", "msg.browser.iframe.not.compliant": "Your browser does not support any iframe", "msg.template.help.json": "For a JSON containing a simple list, leave the 2nd field empty", diff --git a/src/utils/route_utils.py b/src/utils/route_utils.py new file mode 100644 index 00000000..aff615ef --- /dev/null +++ b/src/utils/route_utils.py @@ -0,0 +1,110 @@ +from flask import current_app +from urllib.parse import urlparse +import requests +import xml.etree.ElementTree as ET + +from werkzeug.exceptions import BadRequest, Forbidden + +from .login_utils import current_user + + +def validate_xml_creator(xml_text: str) -> str: + """ + Parse XML text and deny access when dc:creator exists and does not match + the current authenticated user. + + :param xml_text: raw XML payload + :returns: original XML payload when access is allowed + :raises BadRequest: when the payload is not valid XML + :raises Forbidden: when dc:creator does not match the current user + """ + xml_root = get_xml_root(xml_text) + creator = xml_root.find(".//metadata/{*}RDF/{*}Description//{*}creator") + creator_name = creator.text.strip() if creator is not None and creator.text else "" + + if creator_name and creator_name != current_user.username: + raise Forbidden("Not allowed to load this configuration !") + + return xml_text + + +def get_xml_root(xml_text: str): + """ + Parse an XML string and return its root node. + + :param xml_text: raw XML payload + :raises BadRequest: when the payload is not valid XML + """ + try: + return ET.fromstring(xml_text) + except ET.ParseError: + raise BadRequest("XML seems not correct !") + + +def get_xml_identifier(xml_text: str) -> str: + """ + Extract the DCAT identifier from a raw XML payload. + + :param xml_text: raw XML payload + :returns: configuration identifier found in metadata + :raises BadRequest: when the identifier is missing + """ + xml_root = get_xml_root(xml_text) + identifier = xml_root.find(".//metadata/{*}RDF/{*}Description//{*}identifier") + if identifier is None or not identifier.text: + raise BadRequest("Missing XML identifier !") + return identifier.text + + +def get_existing_config_or_404(id: str) -> dict: + """ + Read a configuration from the register and return its metadata. + + :param id: configuration identifier + :returns: register entry for the requested configuration + :raises BadRequest: when the configuration does not exist + """ + config = current_app.register.read_json(id) + if not config: + raise BadRequest("This config doesn't exists !") + return config[0] + + +def authorize_config_mutation(config: dict) -> dict: + """ + Ensure the current user can mutate the given configuration. + + Access is granted only when both the publisher and creator match the + authenticated user context. + + :param config: configuration metadata from the register + :returns: the same configuration metadata when authorized + :raises Forbidden: when the current user is not allowed to modify it + """ + if config["publisher"] != current_user.normalize_name: + raise Forbidden("Not allowed to modify this configuration !") + if config["creator"] != current_user.username: + raise Forbidden("Not allowed to modify this configuration !") + return config + + +def fetch_remote_xml(url: str, timeout: int = 10) -> str: + """ + Retrieve a remote XML document over HTTP(S). + + :param url: remote XML URL + :param timeout: request timeout in seconds + :returns: remote response body as text + :raises BadRequest: when the URL is missing, invalid, or unreachable + """ + if not url: + raise BadRequest("Missing url parameter !") + parsed_url = urlparse(url) + if parsed_url.scheme not in ["http", "https"]: + raise BadRequest("URL must use http or https !") + try: + response = requests.get(url, timeout=timeout) + response.raise_for_status() + except requests.RequestException: + raise BadRequest("Could not retrieve XML !") + return response.text