diff --git a/src/backend/base/langflow/api/v1/files.py b/src/backend/base/langflow/api/v1/files.py index c6d2e03785..229b4b6776 100644 --- a/src/backend/base/langflow/api/v1/files.py +++ b/src/backend/base/langflow/api/v1/files.py @@ -134,8 +134,8 @@ async def download_profile_picture( ): """Download profile picture from local filesystem. - Profile pictures are always stored locally in config_dir/profile_pictures/, - regardless of the storage_type setting (local, s3, etc.). + Profile pictures are first looked up in config_dir/profile_pictures/, + then fallback to the package's bundled profile_pictures directory. """ try: extension = file_name.split(".")[-1] @@ -143,8 +143,15 @@ async def download_profile_picture( config_path = Path(config_dir) # type: ignore[arg-type] file_path = config_path / "profile_pictures" / folder_name / file_name + # Fallback to package bundled profile pictures if not found in config_dir if not file_path.exists(): - raise HTTPException(status_code=404, detail=f"Profile picture {folder_name}/{file_name} not found") + from langflow.initial_setup import setup + + package_path = Path(setup.__file__).parent / "profile_pictures" / folder_name / file_name + if package_path.exists(): + file_path = package_path + else: + raise HTTPException(status_code=404, detail=f"Profile picture {folder_name}/{file_name} not found") content_type = build_content_type_from_extension(extension) # Read file directly from local filesystem using async file operations @@ -163,8 +170,8 @@ async def list_profile_pictures( ): """List profile pictures from local filesystem. - Profile pictures are always stored locally in config_dir/profile_pictures/, - regardless of the storage_type setting (local, s3, etc.). + Profile pictures are first looked up in config_dir/profile_pictures/, + then fallback to the package's bundled profile_pictures directory. """ try: config_dir = settings_service.settings.config_dir @@ -173,9 +180,19 @@ async def list_profile_pictures( people_path = config_path / "profile_pictures" / "People" space_path = config_path / "profile_pictures" / "Space" - # List files directly from local filesystem - bundled with the container + # List files directly from local filesystem people = [f.name for f in people_path.iterdir() if f.is_file()] if people_path.exists() else [] space = [f.name for f in space_path.iterdir() if f.is_file()] if space_path.exists() else [] + + # Fallback to package bundled profile pictures if config_dir is empty + if not people and not space: + from langflow.initial_setup import setup + + package_base = Path(setup.__file__).parent / "profile_pictures" + people_path = package_base / "People" + space_path = package_base / "Space" + people = [f.name for f in people_path.iterdir() if f.is_file()] if people_path.exists() else [] + space = [f.name for f in space_path.iterdir() if f.is_file()] if space_path.exists() else [] except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/src/backend/tests/unit/api/v1/test_files.py b/src/backend/tests/unit/api/v1/test_files.py index 042c98f5f2..2742bf58ab 100644 --- a/src/backend/tests/unit/api/v1/test_files.py +++ b/src/backend/tests/unit/api/v1/test_files.py @@ -487,3 +487,257 @@ async def test_profile_pictures_different_file_types(setup_profile_pictures, fil assert response.status_code == 200 # All profile pictures should be SVGs assert "image/svg+xml" in response.headers["content-type"] + + +# ============================================================================ +# Tests for package fallback functionality (PR #10758) +# ============================================================================ + + +@pytest.fixture +async def empty_config_dir(monkeypatch): + """Fixture that sets up an empty config directory without profile pictures. + + This simulates the scenario where copy_profile_pictures() was never called, + so the endpoints should fallback to the package bundled profile pictures. + """ + temp_dir = tempfile.mkdtemp() + config_path = Path(temp_dir) + + # Create an empty profile_pictures directory (no files inside) + (config_path / "profile_pictures").mkdir(parents=True, exist_ok=True) + + # Override the config_dir setting BEFORE app initialization + monkeypatch.setenv("LANGFLOW_CONFIG_DIR", str(config_path)) + + yield config_path + + # Cleanup + import shutil + + shutil.rmtree(temp_dir, ignore_errors=True) + + +async def test_download_profile_picture_fallback_to_package(empty_config_dir, files_client): # noqa: ARG001 + """Test that profile pictures fallback to package when not in config_dir. + + This tests the core fix from PR #10758 - when profile pictures don't exist + in config_dir, they should be served from the package's bundled directory. + """ + # The 046-rocket.svg should be found in the package's bundled directory + response = await files_client.get("api/v1/files/profile_pictures/Space/046-rocket.svg") + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}. Fallback to package profile pictures should work." + ) + + # Verify content type + assert "image/svg+xml" in response.headers["content-type"] + + # Verify SVG content + content = response.content + assert b"" in content + + +async def test_list_profile_pictures_fallback_to_package(empty_config_dir, files_client): # noqa: ARG001 + """Test that list endpoint fallbacks to package when config_dir is empty. + + This tests the list fallback from PR #10758 - when config_dir has no + profile pictures, it should list from the package's bundled directory. + """ + response = await files_client.get("api/v1/files/profile_pictures/list") + assert response.status_code == 200 + + data = response.json() + assert "files" in data + files = data["files"] + + # Should have files from the package + assert len(files) > 0, "Should have profile pictures from package fallback" + assert any(f.startswith("Space/") for f in files), "Should have Space profile pictures" + assert any(f.startswith("People/") for f in files), "Should have People profile pictures" + + # The bundled rocket should be available + assert "Space/046-rocket.svg" in files + + +async def test_download_profile_picture_not_found_in_both_locations(empty_config_dir, files_client): # noqa: ARG001 + """Test 404 when profile picture doesn't exist in config_dir OR package. + + This ensures the fallback logic correctly returns 404 when the file + is not found in either location. + """ + response = await files_client.get("api/v1/files/profile_pictures/Space/nonexistent-file-xyz.svg") + assert response.status_code == 404 + + data = response.json() + assert "not found" in data["detail"].lower() + assert "Space/nonexistent-file-xyz.svg" in data["detail"] + + +async def test_download_profile_picture_invalid_folder(empty_config_dir, files_client): # noqa: ARG001 + """Test 404 when using an invalid folder name. + + Only 'People' and 'Space' folders should exist in the package. + """ + response = await files_client.get("api/v1/files/profile_pictures/InvalidFolder/file.svg") + assert response.status_code == 404 + + data = response.json() + assert "not found" in data["detail"].lower() + + +async def test_download_profile_picture_config_dir_takes_precedence(setup_profile_pictures, files_client): + """Test that config_dir profile pictures take precedence over package. + + When a file exists in both config_dir and package, the config_dir + version should be served. + """ + config_path = setup_profile_pictures + + # Create a custom rocket SVG in config_dir with identifiable content + custom_svg = b'CUSTOM_CONFIG_DIR_VERSION' + space_dir = config_path / "profile_pictures" / "Space" + space_dir.mkdir(parents=True, exist_ok=True) + (space_dir / "046-rocket.svg").write_bytes(custom_svg) + + response = await files_client.get("api/v1/files/profile_pictures/Space/046-rocket.svg") + assert response.status_code == 200 + + # Should get the config_dir version, not the package version + content = response.content + assert b"CUSTOM_CONFIG_DIR_VERSION" in content + + +async def test_list_profile_pictures_config_dir_takes_precedence(setup_profile_pictures, files_client): + """Test that config_dir listing takes precedence over package. + + When config_dir has profile pictures, they should be listed instead + of the package's bundled ones. + """ + config_path = setup_profile_pictures + + # Ensure we have a file in config_dir + space_dir = config_path / "profile_pictures" / "Space" + space_dir.mkdir(parents=True, exist_ok=True) + (space_dir / "custom-test-file.svg").write_bytes(b"") + + response = await files_client.get("api/v1/files/profile_pictures/list") + assert response.status_code == 200 + + data = response.json() + files = data["files"] + + # Should include our custom file from config_dir + assert "Space/custom-test-file.svg" in files + + +async def test_download_profile_picture_path_traversal_attempt(empty_config_dir, files_client): # noqa: ARG001 + """Test that path traversal attacks are prevented. + + Attempting to access files outside the profile_pictures directory + using '../' should not work. + """ + # Try path traversal to access parent directories + response = await files_client.get("api/v1/files/profile_pictures/../../../etc/passwd") + # Should either be 404 (file not found) or the path should be sanitized + assert response.status_code in [404, 500] + + +async def test_download_profile_picture_special_characters_in_filename(empty_config_dir, files_client): # noqa: ARG001 + """Test handling of special characters in filename. + + Filenames with spaces or special characters should be handled properly. + """ + # Test with URL-encoded space (the real file has a space: "042-space shuttle.svg") + response = await files_client.get("api/v1/files/profile_pictures/Space/042-space%20shuttle.svg") + # Should work if the file exists with that name, or 404 if not + assert response.status_code in [200, 404] + + +async def test_list_profile_pictures_empty_response_format(empty_config_dir, files_client): # noqa: ARG001 + """Test that the list response format is correct even with fallback. + + The response should always have the correct format: {"files": [...]} + """ + response = await files_client.get("api/v1/files/profile_pictures/list") + assert response.status_code == 200 + + data = response.json() + + # Verify response structure + assert isinstance(data, dict) + assert "files" in data + assert isinstance(data["files"], list) + + # Verify file path format (should be "Folder/filename") + for file_path in data["files"]: + assert "/" in file_path, f"File path should contain '/': {file_path}" + folder, filename = file_path.split("/", 1) + assert folder in ["People", "Space"], f"Invalid folder: {folder}" + assert len(filename) > 0, "Filename should not be empty" + + +async def test_download_profile_picture_content_is_valid_svg(empty_config_dir, files_client): # noqa: ARG001 + """Test that downloaded profile pictures are valid SVG files. + + This ensures the fallback serves actual SVG content, not corrupted data. + """ + # Download the rocket from package fallback + response = await files_client.get("api/v1/files/profile_pictures/Space/046-rocket.svg") + assert response.status_code == 200 + + content = response.content + + # Verify it's valid XML/SVG + assert content.startswith((b"<", b"" in content.lower(), "Should have closing svg tag" + + +@pytest.fixture +async def partial_config_dir(monkeypatch): + """Fixture that sets up a config directory with only People folder (no Space). + + This tests the edge case where config_dir is partially populated. + """ + temp_dir = tempfile.mkdtemp() + config_path = Path(temp_dir) + + # Create only People directory with a file + people_dir = config_path / "profile_pictures" / "People" + people_dir.mkdir(parents=True, exist_ok=True) + (people_dir / "test-person.svg").write_bytes(b"") + + # Note: Space directory is NOT created intentionally + + # Override the config_dir setting BEFORE app initialization + monkeypatch.setenv("LANGFLOW_CONFIG_DIR", str(config_path)) + + yield config_path + + # Cleanup + import shutil + + shutil.rmtree(temp_dir, ignore_errors=True) + + +async def test_profile_pictures_fallback_with_partial_config_dir(partial_config_dir, files_client): # noqa: ARG001 + """Test fallback when config_dir has only People folder but not Space. + + This is an edge case where config_dir is partially populated. + """ + # For list: since we have at least one file in People, it should NOT fallback completely + # The current implementation only falls back if BOTH people AND space are empty + response = await files_client.get("api/v1/files/profile_pictures/list") + assert response.status_code == 200 + + data = response.json() + files = data["files"] + + # Should have our People file from config_dir + assert "People/test-person.svg" in files + + # For download: Space files should still work via fallback to package + response = await files_client.get("api/v1/files/profile_pictures/Space/046-rocket.svg") + assert response.status_code == 200, "Space files should fallback to package"