diff --git a/src/creative/assembler.py b/src/creative/assembler.py index c95910a..049dc8c 100644 --- a/src/creative/assembler.py +++ b/src/creative/assembler.py @@ -29,22 +29,60 @@ except ImportError: _MOVIEPY_AVAILABLE = False def _resolve_font() -> str: - """Find a usable TrueType font on the current platform.""" + """Find a usable TrueType font on the current platform. + + Searches for system fonts in order of preference, with fallbacks + for different operating systems. Returns a valid font path or + raises an error if no suitable font is found. + """ candidates = [ - # Linux (Debian/Ubuntu) + # Linux (Debian/Ubuntu) - DejaVu is most reliable "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", # Arch "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", # Fedora + # Linux - Liberation fonts (fallback) + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", # macOS "/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf", + # Windows + "C:\\Windows\\Fonts\\arial.ttf", ] + + # Try each candidate for path in candidates: - if Path(path).exists(): - return path - logger.warning("No system TrueType font found; using Pillow default") - return "Helvetica" + try: + if Path(path).exists(): + logger.debug(f"Using system font: {path}") + return path + except (OSError, ValueError): + # Path might be invalid on some systems + continue + + # If no candidates found, search for any available TrueType font + logger.warning("Preferred fonts not found; searching for any available TrueType font") + try: + import subprocess + result = subprocess.run( + ["find", "/usr/share/fonts", "-name", "*.ttf", "-type", "f"], + capture_output=True, + text=True, + timeout=5, + ) + if result.stdout: + first_font = result.stdout.strip().split("\n")[0] + logger.warning(f"Using fallback font: {first_font}") + return first_font + except Exception as e: + logger.debug(f"Font search failed: {e}") + + # Last resort: raise an error instead of returning an invalid font name + raise RuntimeError( + "No suitable TrueType font found on system. " + "Please install a font package (e.g., fonts-dejavu, fonts-liberation) " + "or set MOVIEPY_FONT environment variable to a valid font path." + ) _DEFAULT_FONT = _resolve_font() diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 67a5be2..06d3751 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -16,6 +16,7 @@ from pathlib import Path from fastapi import FastAPI, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -602,13 +603,54 @@ app = FastAPI( openapi_url="/openapi.json", ) -# CORS middleware + +def _get_cors_origins() -> list[str]: + """Get CORS origins from settings, with sensible defaults.""" + origins = settings.cors_origins + if settings.debug and origins == ["*"]: + return [ + "http://localhost:3000", + "http://localhost:8000", + "http://127.0.0.1:3000", + "http://127.0.0.1:8000", + ] + return origins + + +async def add_security_headers(request: Request, call_next): + """Add security headers to all responses.""" + response = await call_next(request) + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' fonts.googleapis.com cdn.jsdelivr.net; " + "font-src 'self' fonts.gstatic.com; " + "img-src 'self' data: https:; " + "connect-src 'self' ws: wss:; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" + ) + return response + + +app.middleware("http")(add_security_headers) + +app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=["localhost", "127.0.0.1", "*.local", "testserver"], +) + app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_get_cors_origins(), allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], ) # Mount static files diff --git a/tests/creative/test_font_resolution.py b/tests/creative/test_font_resolution.py new file mode 100644 index 0000000..3a05ca7 --- /dev/null +++ b/tests/creative/test_font_resolution.py @@ -0,0 +1,73 @@ +"""Test font resolution logic in the creative module.""" + +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def test_resolve_font_prefers_dejavu(): + """Test that _resolve_font prefers DejaVu fonts when available.""" + from creative.assembler import _resolve_font + + # This test will pass on systems with DejaVu fonts installed + # (most Linux distributions) + font = _resolve_font() + assert isinstance(font, str) + assert font.endswith(".ttf") or font.endswith(".ttc") + assert Path(font).exists() + + +def test_resolve_font_returns_valid_path(): + """Test that _resolve_font returns a valid, existing path.""" + from creative.assembler import _resolve_font + + font = _resolve_font() + assert isinstance(font, str) + # Should be a path, not just a font name + assert "/" in font or "\\" in font + assert Path(font).exists() + + +def test_resolve_font_no_invalid_fallback(): + """Test that _resolve_font never returns invalid font names like 'Helvetica'.""" + from creative.assembler import _resolve_font + + font = _resolve_font() + # Should not return bare font names that Pillow can't find + assert font not in ["Helvetica", "Arial", "Times New Roman"] + # Should be a valid path + assert Path(font).exists() + + +@patch("creative.assembler.Path.exists") +@patch("subprocess.run") +def test_resolve_font_fallback_search(mock_run, mock_exists): + """Test that _resolve_font falls back to searching for any TTF.""" + # Mock: no preferred fonts exist + mock_exists.return_value = False + + # Mock: subprocess finds a font + mock_result = MagicMock() + mock_result.stdout = "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf\n" + mock_run.return_value = mock_result + + from creative.assembler import _resolve_font + + font = _resolve_font() + assert "LiberationSans-Regular.ttf" in font + + +@patch("creative.assembler.Path.exists") +@patch("subprocess.run") +def test_resolve_font_raises_on_no_fonts(mock_run, mock_exists): + """Test that _resolve_font raises RuntimeError when no fonts are found.""" + # Mock: no fonts found anywhere + mock_exists.return_value = False + mock_result = MagicMock() + mock_result.stdout = "" + mock_run.return_value = mock_result + + from creative.assembler import _resolve_font + + with pytest.raises(RuntimeError, match="No suitable TrueType font found"): + _resolve_font() diff --git a/tests/dashboard/test_security_headers.py b/tests/dashboard/test_security_headers.py new file mode 100644 index 0000000..68651be --- /dev/null +++ b/tests/dashboard/test_security_headers.py @@ -0,0 +1,61 @@ +"""Test security headers middleware in FastAPI app.""" + +import pytest +from fastapi.testclient import TestClient + + +def test_security_headers_present(client: TestClient): + """Test that security headers are present in all responses.""" + response = client.get("/") + + # Check for security headers + assert "X-Frame-Options" in response.headers + assert response.headers["X-Frame-Options"] == "SAMEORIGIN" + + assert "X-Content-Type-Options" in response.headers + assert response.headers["X-Content-Type-Options"] == "nosniff" + + assert "X-XSS-Protection" in response.headers + assert response.headers["X-XSS-Protection"] == "1; mode=block" + + assert "Referrer-Policy" in response.headers + assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin" + + assert "Content-Security-Policy" in response.headers + + +def test_csp_header_content(client: TestClient): + """Test that Content Security Policy is properly configured.""" + response = client.get("/") + csp = response.headers.get("Content-Security-Policy", "") + + # Should restrict default-src to self + assert "default-src 'self'" in csp + + # Should allow scripts from self and CDN + assert "script-src 'self' 'unsafe-inline' cdn.jsdelivr.net" in csp + + # Should allow styles from self, CDN, and Google Fonts + assert "style-src 'self' 'unsafe-inline' fonts.googleapis.com cdn.jsdelivr.net" in csp + + # Should restrict frame ancestors to self + assert "frame-ancestors 'self'" in csp + + +def test_cors_headers_restricted(client: TestClient): + """Test that CORS is properly restricted (not allow-origins: *).""" + response = client.get("/") + + # Should not have overly permissive CORS + # (The actual CORS headers depend on the origin of the request, + # so we just verify the app doesn't crash with permissive settings) + assert response.status_code == 200 + + +def test_health_endpoint_has_security_headers(client: TestClient): + """Test that security headers are present on all endpoints.""" + response = client.get("/health") + + assert "X-Frame-Options" in response.headers + assert "X-Content-Type-Options" in response.headers + assert "Content-Security-Policy" in response.headers