fix: Add fallback to bundled images for profile pictures (#10758)

* add profile picture nullable validation

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* add tests profile pic

* [autofix.ci] apply automated fixes

* ruff fixes

* add validation to images read file compnent

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* fix ruff

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Cristhian Zanforlin Lousa
2025-12-01 12:56:28 -03:00
committed by GitHub
parent 9b9e353842
commit 677f87f99b
2 changed files with 277 additions and 6 deletions

View File

@ -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

View File

@ -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"<svg" in content
assert b"</svg>" 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'<svg xmlns="http://www.w3.org/2000/svg"><text>CUSTOM_CONFIG_DIR_VERSION</text></svg>'
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"<svg></svg>")
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"<?xml")), "Should start with XML/SVG tag"
assert b"<svg" in content.lower(), "Should contain svg tag"
assert b"</svg>" 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"<svg><circle/></svg>")
# 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"