diff --git a/openeo_driver/dummy/dummy_backend.py b/openeo_driver/dummy/dummy_backend.py index 9301ae3f..11d5d697 100644 --- a/openeo_driver/dummy/dummy_backend.py +++ b/openeo_driver/dummy/dummy_backend.py @@ -925,6 +925,22 @@ def get_result_assets(self, job_id: str, user_id: str) -> Dict[str, dict]: "type": "Polygon", "coordinates": [[[0.0, 50.0], [0.0, 55.0], [5.0, 55.0], [5.0, 50.0], [0.0, 50.0]]], }, + "datetime": "1970-01-01T00:00:00Z", + }, + "subfolder/subsubfolder/output.tiff": { + "output_dir": f"{self._output_root()}/{job_id}", + "href": f"{self._output_root()}/{job_id}/subfolder/subsubfolder/output.tiff", + "type": "image/tiff; application=geotiff", + "roles": ["data"], + "bands": [Band(name="NDVI", wavelength_um=1.23)], + "nodata": 123, + "instruments": "MSI", + "bbox": [0.0, 50.0, 5.0, 55.0], + "geometry": { + "type": "Polygon", + "coordinates": [[[0.0, 50.0], [0.0, 55.0], [5.0, 55.0], [5.0, 50.0], [0.0, 50.0]]], + }, + "datetime": "1970-01-01T00:00:00Z", }, } elif job_id == "j-26032411111111111111111111111111": diff --git a/openeo_driver/urlsigning.py b/openeo_driver/urlsigning.py index bd4f9829..2c6b2248 100644 --- a/openeo_driver/urlsigning.py +++ b/openeo_driver/urlsigning.py @@ -1,5 +1,6 @@ import logging import operator +import re import time from functools import reduce from hashlib import md5 @@ -10,6 +11,11 @@ _log = logging.getLogger(__name__) +def is_secure_key(s: str) -> bool: + """Return True if s looks like a URL signing key (MD5 hex digest).""" + return bool(re.compile(r"^[0-9a-f]{32}$").match(s)) + + class UrlSigner: def __init__(self, secret: str, expiration: int = None): self._secret = secret diff --git a/openeo_driver/views.py b/openeo_driver/views.py index a4e21363..1802f76a 100644 --- a/openeo_driver/views.py +++ b/openeo_driver/views.py @@ -1,3 +1,4 @@ +import binascii import copy import functools import json @@ -57,6 +58,7 @@ from openeo_driver.datacube import DriverMlModel from openeo_driver.dry_run import DryRunDataTracer from openeo_driver.errors import ( + CredentialsInvalidException, FeatureUnsupportedException, FilePathInvalidException, InternalException, @@ -79,6 +81,7 @@ from openeo_driver.util.logging import ExtraLoggingFilter, FlaskRequestCorrelationIdLogging from openeo_driver.util.stac import sniff_stac_extension_prefix from openeo_driver.util.stac_utils import get_files_from_stac_catalog +from openeo_driver.urlsigning import is_secure_key from openeo_driver.utils import EvalEnv, smart_bool @@ -1489,30 +1492,40 @@ def _stream_from_s3(s3_url, *, filename, mimetype: Optional[str], bytes_range: O raise - @blueprint.route('/jobs//results/items///', methods=['GET']) + @blueprint.route("/jobs//results/items///", methods=["GET"]) def get_job_result_item_signed(job_id, user_base64, secure_key, item_id): + try: + user_id = user_id_b64_decode(user_base64) + except (binascii.Error, UnicodeDecodeError): + full_item_id = "/".join([user_base64, secure_key, item_id]) + user = auth_handler.get_user_from_bearer_token(request) + return _get_job_result_item(job_id, full_item_id, user.user_id) expires = request.args.get('expires') signer = get_backend_config().url_signer - user_id = user_id_b64_decode(user_base64) signer.verify_job_item(signature=secure_key, job_id=job_id, user_id=user_id, item_id=item_id, expires=expires) return _get_job_result_item(job_id, item_id, user_id) @api_endpoint - @blueprint.route('/jobs//results/items11///', methods=['GET']) + @blueprint.route("/jobs//results/items11///", methods=["GET"]) def get_job_result_item11_signed(job_id, user_base64, secure_key, item_id): + try: + user_id = user_id_b64_decode(user_base64) + except (binascii.Error, UnicodeDecodeError): + full_item_id = "/".join([user_base64, secure_key, item_id]) + user = auth_handler.get_user_from_bearer_token(request) + return _get_job_result_item11(job_id, full_item_id, user.user_id) expires = request.args.get('expires') signer = get_backend_config().url_signer - user_id = user_id_b64_decode(user_base64) signer.verify_job_item(signature=secure_key, job_id=job_id, user_id=user_id, item_id=item_id, expires=expires) return _get_job_result_item11(job_id, item_id, user_id) - @blueprint.route('/jobs//results/items/', methods=['GET']) + @blueprint.route("/jobs//results/items/", methods=["GET"]) @auth_handler.requires_bearer_auth def get_job_result_item(job_id: str, item_id: str, user: User) -> flask.Response: return _get_job_result_item(job_id, item_id, user.user_id) @api_endpoint(version=ComparableVersion("1.1.0").or_higher) - @blueprint.route('/jobs//results/items11/', methods=['GET']) + @blueprint.route("/jobs//results/items11/", methods=["GET"]) @auth_handler.requires_bearer_auth def get_job_result_item11(job_id: str, item_id: str, user: User) -> flask.Response: return _get_job_result_item11(job_id, item_id, user.user_id) @@ -1927,9 +1940,14 @@ def download_job_result(job_id, filename, user: User): @blueprint.route("/jobs//results/assets///", methods=["GET"]) def download_job_result_signed(job_id, user_base64, secure_key, filename): + try: + user_id = user_id_b64_decode(user_base64) + except (binascii.Error, UnicodeDecodeError): + full_filename = "/".join([user_base64, secure_key, filename]) + user = auth_handler.get_user_from_bearer_token(request) + return _download_job_result(job_id=job_id, filename=full_filename, user_id=user.user_id) expires = request.args.get('expires') signer = get_backend_config().url_signer - user_id = user_id_b64_decode(user_base64) signer.verify_job_asset( signature=secure_key, job_id=job_id, user_id=user_id, filename=filename, expires=expires diff --git a/tests/test_views.py b/tests/test_views.py index ab8a40dd..52ac7ad4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -3525,12 +3525,12 @@ def test_download_result_nested_path(self, api110, tmp_path): output_root = Path(tmp_path) jobs = {"j-24111211111111111111111111111111": {"status": "finished"}} with self._fresh_job_registry(output_root=output_root, jobs=jobs): - output = output_root / "j-24111211111111111111111111111111/subfolder/output.tiff" + output = output_root / "j-24111211111111111111111111111111/subfolder/subsubfolder/output.tiff" output.parent.mkdir(parents=True) with output.open("wb") as f: f.write(b"tiffdata") resp = api110.get( - "/jobs/j-24111211111111111111111111111111/results/assets/subfolder/output.tiff", + "/jobs/j-24111211111111111111111111111111/results/assets/subfolder/subsubfolder/output.tiff", headers=self.AUTH_HEADER, ) assert resp.assert_status_code(200).data == b"tiffdata" @@ -4024,6 +4024,44 @@ def test_get_vector_cube_job_result_item( extensions=resp_data.get("stac_extensions", []), ) + def test_get_job_result_item_with_temporal_extent_on_asset_nested(self, flask_app, api110): + with self._fresh_job_registry(): + resp = api110.get( + f"/jobs/j-24111211111111111111111111111111/results/items/subfolder/output.tiff", + headers=self.AUTH_HEADER, + ) + + resp_data = resp.assert_status_code(200).json + assert resp_data["assets"]["subfolder/output.tiff"]["eo:bands"][0]["name"] == "NDVI" + + assert resp.headers["Content-Type"] == "application/geo+json" + + pystac.validation.stac_validator.JsonSchemaSTACValidator().validate( + stac_dict=resp_data, + stac_object_type=pystac.STACObjectType.ITEM, + stac_version=resp_data.get("stac_version", "1.0.0"), + extensions=resp_data.get("stac_extensions", []), + ) + + def test_get_job_result_item_with_temporal_extent_on_asset_double_nested(self, flask_app, api110): + with self._fresh_job_registry(): + resp = api110.get( + f"/jobs/j-24111211111111111111111111111111/results/items/subfolder/subsubfolder/output.tiff", + headers=self.AUTH_HEADER, + ) + + resp_data = resp.assert_status_code(200).json + assert resp_data["assets"]["subfolder/subsubfolder/output.tiff"]["eo:bands"][0]["name"] == "NDVI" + + assert resp.headers["Content-Type"] == "application/geo+json" + + pystac.validation.stac_validator.JsonSchemaSTACValidator().validate( + stac_dict=resp_data, + stac_object_type=pystac.STACObjectType.ITEM, + stac_version=resp_data.get("stac_version", "1.0.0"), + extensions=resp_data.get("stac_extensions", []), + ) + def test_get_job_result_item_with_temporal_extent_on_asset(self, flask_app, api110): with self._fresh_job_registry(): resp = api110.get(