diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index 939f320..4249482 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -343,6 +343,17 @@ "required": [], "additionalProperties": false }, + "import_extension": { + "$id": "opengeodeweb_back/import_extension", + "route": "/import_extension", + "methods": [ + "POST" + ], + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, "geographic_coordinate_systems": { "$id": "opengeodeweb_back/geographic_coordinate_systems", "route": "/geographic_coordinate_systems", diff --git a/requirements.txt b/requirements.txt index 2dfb902..08d64bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,4 +60,3 @@ werkzeug==3.1.2 # flask # flask-cors -opengeodeweb-microservice==1.*,>=1.0.11 diff --git a/src/opengeodeweb_back/app.py b/src/opengeodeweb_back/app.py index bf16739..9b60898 100644 --- a/src/opengeodeweb_back/app.py +++ b/src/opengeodeweb_back/app.py @@ -147,6 +147,9 @@ def run_server() -> None: ) args = parser.parse_args() app.config.update(DATA_FOLDER_PATH=args.data_folder_path) + app.config.update( + EXTENSIONS_FOLDER_PATH=os.path.join(args.data_folder_path, "extensions") + ) app.config.update(UPLOAD_FOLDER=args.upload_folder_path) app.config.update(MINUTES_BEFORE_TIMEOUT=args.timeout) flask_cors.CORS(app, origins=args.allowed_origins) diff --git a/src/opengeodeweb_back/app_config.py b/src/opengeodeweb_back/app_config.py index 095b275..2b17c4d 100644 --- a/src/opengeodeweb_back/app_config.py +++ b/src/opengeodeweb_back/app_config.py @@ -24,6 +24,7 @@ class ProdConfig(Config): MINUTES_BEFORE_TIMEOUT = "1" SECONDS_BETWEEN_SHUTDOWNS = "10" DATA_FOLDER_PATH = "/data" + EXTENSIONS_FOLDER_PATH = os.path.join(DATA_FOLDER_PATH, "extensions") class DevConfig(Config): @@ -33,3 +34,4 @@ class DevConfig(Config): SECONDS_BETWEEN_SHUTDOWNS = "10" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_FOLDER_PATH = os.path.join(BASE_DIR, "data") + EXTENSIONS_FOLDER_PATH = os.path.join(DATA_FOLDER_PATH, "extensions") diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index 4f2108d..25aa7a3 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -504,3 +504,78 @@ def import_project() -> flask.Response: except KeyError: snapshot = {} return flask.make_response({"snapshot": snapshot}, 200) + + +@routes.route( + schemas_dict["import_extension"]["route"], + methods=schemas_dict["import_extension"]["methods"], +) +def import_extension() -> flask.Response: + """Import a .vext extension file and extract its contents.""" + utils_functions.validate_request(flask.request, schemas_dict["import_extension"]) + + if "file" not in flask.request.files: + flask.abort(400, "No .vext file provided under 'file'") + + vext_file = flask.request.files["file"] + assert vext_file.filename is not None + filename = werkzeug.utils.secure_filename(os.path.basename(vext_file.filename)) + + if not filename.lower().endswith(".vext"): + flask.abort(400, "Uploaded file must be a .vext") + + # Create extensions directory in the data folder + extensions_folder = flask.current_app.config["EXTENSIONS_FOLDER_PATH"] + os.makedirs(extensions_folder, exist_ok=True) + + extension_name = ( + filename.rsplit("-", 1)[0] if "-" in filename else filename.replace(".vext", "") + ) + extension_path = os.path.join(extensions_folder, extension_name) + + # Remove existing extension if present + if os.path.exists(extension_path): + shutil.rmtree(extension_path) + + os.makedirs(extension_path, exist_ok=True) + + # Extract the .vext file + vext_file.stream.seek(0) + with zipfile.ZipFile(vext_file.stream) as zip_archive: + zip_archive.extractall(extension_path) + + # Find the extracted files + dist_path = os.path.join(extension_path, "dist") + if not os.path.exists(dist_path): + flask.abort(400, "Invalid .vext file: missing dist folder") + + # Look for the backend executable and frontend JS + backend_executable = None + frontend_file = None + + for file in os.listdir(dist_path): + file_path = os.path.join(dist_path, file) + if os.path.isfile(file_path): + if file.endswith(".es.js"): + frontend_file = file_path + elif not file.endswith(".js") and not file.endswith(".css"): + backend_executable = file_path + os.chmod(backend_executable, 0o755) + + if not frontend_file: + flask.abort(400, "Invalid .vext file: missing frontend JavaScript") + if not backend_executable: + flask.abort(400, "Invalid .vext file: missing backend executable") + + assert frontend_file is not None + with open(frontend_file, "r", encoding="utf-8") as f: + frontend_content = f.read() + + return flask.make_response( + { + "extension_name": extension_name, + "frontend_content": frontend_content, + "backend_path": backend_executable, + }, + 200, + ) diff --git a/src/opengeodeweb_back/routes/schemas/__init__.py b/src/opengeodeweb_back/routes/schemas/__init__.py index 4ad76b8..5d37ca1 100644 --- a/src/opengeodeweb_back/routes/schemas/__init__.py +++ b/src/opengeodeweb_back/routes/schemas/__init__.py @@ -9,6 +9,7 @@ from .kill import * from .inspect_file import * from .import_project import * +from .import_extension import * from .geographic_coordinate_systems import * from .geode_objects_and_output_extensions import * from .export_project import * diff --git a/src/opengeodeweb_back/routes/schemas/import_extension.json b/src/opengeodeweb_back/routes/schemas/import_extension.json new file mode 100644 index 0000000..4d80f55 --- /dev/null +++ b/src/opengeodeweb_back/routes/schemas/import_extension.json @@ -0,0 +1,10 @@ +{ + "route": "/import_extension", + "methods": [ + "POST" + ], + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false +} diff --git a/src/opengeodeweb_back/routes/schemas/import_extension.py b/src/opengeodeweb_back/routes/schemas/import_extension.py new file mode 100644 index 0000000..256cb5f --- /dev/null +++ b/src/opengeodeweb_back/routes/schemas/import_extension.py @@ -0,0 +1,10 @@ +from dataclasses_json import DataClassJsonMixin +from dataclasses import dataclass + + +@dataclass +class ImportExtension(DataClassJsonMixin): + def __post_init__(self) -> None: + print(self, flush=True) + + pass diff --git a/tests/test_models_routes.py b/tests/test_models_routes.py index c31ae71..6389b6b 100644 --- a/tests/test_models_routes.py +++ b/tests/test_models_routes.py @@ -225,3 +225,89 @@ def test_save_viewable_workflow_from_object(client: FlaskClient) -> None: assert isinstance(data_id, str) and len(data_id) > 0 assert response.get_json()["geode_object_type"] == "EdgedCurve3D" assert response.get_json()["viewable_file"].endswith(".vtp") + + +def test_import_extension_route(client: FlaskClient, tmp_path: Path) -> None: + """Test importing a .vext extension file.""" + route = "/opengeodeweb_back/import_extension" + original_data_folder = client.application.config["DATA_FOLDER_PATH"] + new_data_folder = os.path.join(str(tmp_path), "extension_test_data") + client.application.config["DATA_FOLDER_PATH"] = new_data_folder + client.application.config["EXTENSIONS_FOLDER_PATH"] = os.path.join( + new_data_folder, "extensions" + ) + vext_path = tmp_path / "test-extension-1.0.0.vext" + with zipfile.ZipFile(vext_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: + zipf.writestr( + "dist/test-extension-extension.es.js", + "export const metadata = { id: 'test-extension', name: 'Test Extension' };", + ) + zipf.writestr("dist/test-extension-back", "#!/bin/bash\necho 'mock backend'") + zipf.writestr("dist/test-extension.css", ".test { color: red; }") + with open(vext_path, "rb") as f: + response = client.post( + route, + data={"file": (f, "test-extension-1.0.0.vext")}, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + json_data = response.get_json() + assert "extension_name" in json_data + assert "frontend_content" in json_data + assert "backend_path" in json_data + assert json_data["extension_name"] == "test-extension" + extensions_folder = os.path.join( + client.application.config["DATA_FOLDER_PATH"], "extensions" + ) + extension_path = os.path.join(extensions_folder, "test-extension") + assert os.path.exists(extension_path) + dist_path = os.path.join(extension_path, "dist") + assert os.path.exists(dist_path) + + # Verify frontend content is returned + frontend_content = json_data["frontend_content"] + assert isinstance(frontend_content, str) + assert len(frontend_content) > 0 + assert "export const metadata" in frontend_content + + backend_exec = json_data["backend_path"] + assert os.path.exists(backend_exec) + assert os.access(backend_exec, os.X_OK) + client.application.config["DATA_FOLDER_PATH"] = original_data_folder + + +def test_import_extension_invalid_file(client: FlaskClient, tmp_path: Path) -> None: + """Test importing an invalid .vext file (missing dist folder).""" + route = "/opengeodeweb_back/import_extension" + original_data_folder = client.application.config["DATA_FOLDER_PATH"] + new_data_folder = os.path.join(str(tmp_path), "extension_invalid_test") + client.application.config["DATA_FOLDER_PATH"] = new_data_folder + client.application.config["EXTENSIONS_FOLDER_PATH"] = os.path.join( + new_data_folder, "extensions" + ) + vext_path = tmp_path / "invalid-extension.vext" + with zipfile.ZipFile(vext_path, "w") as zipf: + zipf.writestr("README.md", "This is invalid") + with open(vext_path, "rb") as f: + response = client.post( + route, + data={"file": (f, "invalid-extension.vext")}, + content_type="multipart/form-data", + ) + assert response.status_code == 400 + client.application.config["DATA_FOLDER_PATH"] = original_data_folder + + +def test_import_extension_wrong_extension(client: FlaskClient, tmp_path: Path) -> None: + """Test uploading a file with wrong extension.""" + route = "/opengeodeweb_back/import_extension" + wrong_file = tmp_path / "not-an-extension.zip" + with open(wrong_file, "wb") as f: + f.write(b"test content") + with open(wrong_file, "rb") as f: + response = client.post( + route, + data={"file": (f, "not-an-extension.zip")}, + content_type="multipart/form-data", + ) + assert response.status_code == 400