fix: multi-tenant isolation for asset serving, image-studio ownership check, ts compile error
This commit is contained in:
@@ -38,6 +38,15 @@ MIME_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_ownership(url_user_id: str, current_user: Dict[str, Any]) -> str:
|
||||||
|
"""Verify the URL user_id matches the authenticated user. Returns sanitized user_id."""
|
||||||
|
raw = current_user.get("id") or current_user.get("user_id") or current_user.get("clerk_user_id")
|
||||||
|
authed_id = str(raw) if raw else ""
|
||||||
|
if not authed_id or sanitize_user_id(url_user_id) != sanitize_user_id(authed_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: user mismatch")
|
||||||
|
return sanitize_user_id(url_user_id)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path:
|
def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path:
|
||||||
"""Resolve asset path in user workspace with path-traversal protection."""
|
"""Resolve asset path in user workspace with path-traversal protection."""
|
||||||
safe_user_id = sanitize_user_id(user_id)
|
safe_user_id = sanitize_user_id(user_id)
|
||||||
@@ -67,6 +76,7 @@ async def serve_avatar(
|
|||||||
"""Serve avatar images. Supports auth via Authorization header or ?token= query param.
|
"""Serve avatar images. Supports auth via Authorization header or ?token= query param.
|
||||||
Falls back to images/ directory for backward compatibility with old asset library entries."""
|
Falls back to images/ directory for backward compatibility with old asset library entries."""
|
||||||
require_authenticated_user(current_user)
|
require_authenticated_user(current_user)
|
||||||
|
_verify_ownership(user_id, current_user)
|
||||||
|
|
||||||
safe_filename = os.path.basename(filename)
|
safe_filename = os.path.basename(filename)
|
||||||
file_path = _resolve_asset_path(user_id, "avatars", safe_filename)
|
file_path = _resolve_asset_path(user_id, "avatars", safe_filename)
|
||||||
@@ -95,6 +105,7 @@ async def serve_voice_sample(
|
|||||||
which cannot send Authorization headers.
|
which cannot send Authorization headers.
|
||||||
"""
|
"""
|
||||||
require_authenticated_user(current_user)
|
require_authenticated_user(current_user)
|
||||||
|
_verify_ownership(user_id, current_user)
|
||||||
|
|
||||||
safe_filename = os.path.basename(filename)
|
safe_filename = os.path.basename(filename)
|
||||||
file_path = _resolve_asset_path(user_id, "voice_samples", safe_filename)
|
file_path = _resolve_asset_path(user_id, "voice_samples", safe_filename)
|
||||||
@@ -117,6 +128,7 @@ async def serve_image(
|
|||||||
):
|
):
|
||||||
"""Serve generated/uploaded images. Supports auth via Authorization header or ?token= query param."""
|
"""Serve generated/uploaded images. Supports auth via Authorization header or ?token= query param."""
|
||||||
require_authenticated_user(current_user)
|
require_authenticated_user(current_user)
|
||||||
|
_verify_ownership(user_id, current_user)
|
||||||
|
|
||||||
safe_filename = os.path.basename(filename)
|
safe_filename = os.path.basename(filename)
|
||||||
file_path = _resolve_asset_path(user_id, "images", safe_filename)
|
file_path = _resolve_asset_path(user_id, "images", safe_filename)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from services.subscription import UsageTrackingService, PricingService
|
|||||||
from models.subscription_models import APIProvider, UsageSummary
|
from models.subscription_models import APIProvider, UsageSummary
|
||||||
from utils.asset_tracker import save_asset_to_library
|
from utils.asset_tracker import save_asset_to_library
|
||||||
from utils.file_storage import save_file_safely, generate_unique_filename, sanitize_filename
|
from utils.file_storage import save_file_safely, generate_unique_filename, sanitize_filename
|
||||||
|
from services.content_asset_service import ContentAssetService
|
||||||
|
from models.content_asset_models import ContentAsset
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/images", tags=["images"])
|
router = APIRouter(prefix="/api/images", tags=["images"])
|
||||||
@@ -1022,13 +1024,29 @@ def edit(
|
|||||||
@router.get("/image-studio/images/{image_filename:path}")
|
@router.get("/image-studio/images/{image_filename:path}")
|
||||||
async def serve_image_studio_image(
|
async def serve_image_studio_image(
|
||||||
image_filename: str,
|
image_filename: str,
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Serve a generated or edited image from Image Studio."""
|
"""Serve a generated or edited image from Image Studio.
|
||||||
|
Verifies the authenticated user owns the image via asset library lookup."""
|
||||||
try:
|
try:
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
user_id = current_user.get("id") or current_user.get("user_id") or current_user.get("clerk_user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="User ID not found")
|
||||||
|
|
||||||
|
# Verify ownership: the requesting user must have a content_assets record for this file_url
|
||||||
|
full_url = f"/api/images/image-studio/images/{image_filename}"
|
||||||
|
service = ContentAssetService(db)
|
||||||
|
owned = db.query(ContentAsset).filter(
|
||||||
|
ContentAsset.user_id == user_id,
|
||||||
|
ContentAsset.file_url == full_url,
|
||||||
|
).first()
|
||||||
|
if not owned:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: image not found in your library")
|
||||||
|
|
||||||
# Determine if it's an edited image or regular image
|
# Determine if it's an edited image or regular image
|
||||||
base_dir = Path(__file__).parent.parent
|
base_dir = Path(__file__).parent.parent
|
||||||
image_studio_dir = (base_dir / "image_studio_images").resolve()
|
image_studio_dir = (base_dir / "image_studio_images").resolve()
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
|||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Checking subscription for user:', userId);
|
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Checking subscription for user:', userId);
|
||||||
const response = await apiClient.get(`/api/subscription/status/${userId}`);
|
const response = await apiClient.get(`/api/subscription/status/${userId}`);
|
||||||
const subscriptionData = response.data.data;
|
let subscriptionData = response.data.data;
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Subscription data received:', { active: subscriptionData?.active, plan: subscriptionData?.plan });
|
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Subscription data received:', { active: subscriptionData?.active, plan: subscriptionData?.plan });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user