From 0b525d6996141a58082900c8b71b0c16b07affcf Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 17:18:28 +0000 Subject: [PATCH] Add public profile view endpoint: favorites, friends, privacy enforcement (US-3) - Implemented GET /api/profile/{user_id} in backend/app/routers/profiles.py - Server-side privacy enforcement for profile_visibility, favorites_visibility, and friends_list_visibility - Block relationships enforce restricted view (no bio, empty favorites, empty friends) - Non-friends restricted when profile_visibility or sections set to friends-only - Asset public check for avatar in restricted view - No private user fields leaked (email, password excluded) - Added 7 integration tests in backend/tests/test_profile.py covering all privacy and block permutations Independently verified: 26/26 profile tests passing. --- backend/app/routers/profiles.py | 162 +++++++++++++++++++++++++++++++- backend/tests/test_profile.py | 153 ++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/profiles.py b/backend/app/routers/profiles.py index 9fa49f9..ba4c1e3 100644 --- a/backend/app/routers/profiles.py +++ b/backend/app/routers/profiles.py @@ -17,7 +17,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field, field_validator -from sqlalchemy import select +from sqlalchemy import select, or_, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -30,6 +30,8 @@ from app.models.profile import ( ProfileFavorite, ProfileVisibility, ) +from app.models.social import Friendship, Block, FriendshipStatus +from app.models.user import User from app.models.world import World router = APIRouter() @@ -43,6 +45,30 @@ _VISIBILITY_VALUES = {v.value for v in ProfileVisibility} # ──────────────────────────── Pydantic schemas ──────────────────────────────── +class PublicProfileWorld(BaseModel): + id: int + name: str + + model_config = {"from_attributes": True} + + +class PublicProfileUser(BaseModel): + id: int + username: str + + model_config = {"from_attributes": True} + + +class PublicProfileResponse(BaseModel): + user_id: int + username: str + avatar_asset_id: int | None = None + bio: str | None = None + favorites: list[PublicProfileWorld] = Field(default_factory=list) + friends: list[PublicProfileUser] = Field(default_factory=list) + is_restricted: bool = False + + class ProfileResponse(BaseModel): user_id: int avatar_asset_id: int | None @@ -203,3 +229,137 @@ async def replace_my_favorites(payload: FavoritesUpdate, current_user: CurrentUs await db.commit() return FavoritesResponse(world_ids=world_ids) + + +@router.get("/{user_id}", response_model=PublicProfileResponse) +async def get_public_profile(user_id: int, current_user: CurrentUser, db: DbDep): + """Return the public profile for a user, respecting server-side privacy + settings and block relationships.""" + user_res = await db.execute(select(User).where(User.id == user_id)) + target_user = user_res.scalar_one_or_none() + if target_user is None or not target_user.is_active: + raise HTTPException(status_code=404, detail="User not found") + + profile = await db.get(Profile, user_id) + avatar_asset = None + if profile and profile.avatar_asset_id: + avatar_asset = await db.get(Asset, profile.avatar_asset_id) + + privacy = dict(DEFAULT_PRIVACY) + if profile and profile.privacy_json: + privacy.update(profile.privacy_json) + + is_self = current_user.id == user_id + is_blocked = False + is_friend = False + + if not is_self: + block_res = await db.execute( + select(Block.id).where( + or_( + and_(Block.blocker_id == current_user.id, Block.blocked_id == user_id), + and_(Block.blocker_id == user_id, Block.blocked_id == current_user.id), + ) + ) + ) + is_blocked = block_res.scalar_one_or_none() is not None + + if not is_blocked: + friend_res = await db.execute( + select(Friendship.id).where( + or_( + and_(Friendship.requester_id == current_user.id, Friendship.addressee_id == user_id), + and_(Friendship.requester_id == user_id, Friendship.addressee_id == current_user.id), + ), + Friendship.status == FriendshipStatus.ACCEPTED, + ) + ) + is_friend = friend_res.scalar_one_or_none() is not None + + profile_vis = privacy.get("profile_visibility", ProfileVisibility.PUBLIC.value) + if profile_vis is False: + profile_vis = ProfileVisibility.PRIVATE.value + elif profile_vis is True: + profile_vis = ProfileVisibility.PUBLIC.value + + is_profile_restricted = False + if is_blocked: + is_profile_restricted = True + elif not is_self: + if profile_vis == ProfileVisibility.PRIVATE.value: + is_profile_restricted = True + elif profile_vis == ProfileVisibility.FRIENDS.value and not is_friend: + is_profile_restricted = True + + if is_profile_restricted: + avatar_id = None + if profile and profile.avatar_asset_id: + if avatar_asset and avatar_asset.is_public: + avatar_id = profile.avatar_asset_id + return PublicProfileResponse( + user_id=target_user.id, + username=target_user.username, + avatar_asset_id=avatar_id, + bio=None, + favorites=[], + friends=[], + is_restricted=True, + ) + + avatar_id = profile.avatar_asset_id if profile else None + bio = profile.bio if profile else None + + fav_vis = privacy.get("favorites_visibility", ProfileVisibility.PUBLIC.value) + if fav_vis is False: + fav_vis = ProfileVisibility.PRIVATE.value + elif fav_vis is True: + fav_vis = ProfileVisibility.PUBLIC.value + + can_see_favorites = is_self or (fav_vis == ProfileVisibility.PUBLIC.value) or (fav_vis == ProfileVisibility.FRIENDS.value and is_friend) + favorites_list = [] + if can_see_favorites: + fav_query = ( + select(World.id, World.name) + .join(ProfileFavorite, ProfileFavorite.world_id == World.id) + .where(ProfileFavorite.user_id == user_id) + .order_by(ProfileFavorite.created_at.desc()) + ) + fav_res = await db.execute(fav_query) + favorites_list = [PublicProfileWorld(id=row[0], name=row[1]) for row in fav_res.all()] + + fr_vis = privacy.get("friends_list_visibility", ProfileVisibility.PUBLIC.value) + if fr_vis is False: + fr_vis = ProfileVisibility.PRIVATE.value + elif fr_vis is True: + fr_vis = ProfileVisibility.PUBLIC.value + + can_see_friends = is_self or (fr_vis == ProfileVisibility.PUBLIC.value) or (fr_vis == ProfileVisibility.FRIENDS.value and is_friend) + friends_list = [] + if can_see_friends: + fr_res = await db.execute( + select(Friendship).where( + or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id), + Friendship.status == FriendshipStatus.ACCEPTED, + ) + ) + friendships = fr_res.scalars().all() + other_ids = [ + fr.addressee_id if fr.requester_id == user_id else fr.requester_id + for fr in friendships + ] + if other_ids: + users_res = await db.execute( + select(User.id, User.username).where(User.id.in_(other_ids), User.is_active == True) + ) + friends_list = [PublicProfileUser(id=row[0], username=row[1]) for row in users_res.all()] + + return PublicProfileResponse( + user_id=target_user.id, + username=target_user.username, + avatar_asset_id=avatar_id, + bio=bio, + favorites=favorites_list, + friends=friends_list, + is_restricted=False, + ) + diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 88966ec..fae7de4 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -251,3 +251,156 @@ class TestFavorites: client = TestClient(app) assert client.get("/api/profile/me/favorites").status_code == 401 assert client.put("/api/profile/me/favorites", json={"world_ids": []}).status_code == 401 + + +class TestPublicProfile: + def test_public_profile_full_view(self): + client = TestClient(app) + h_alice = _auth_headers(client, "pub_alice") + alice_id = _me_id(client, h_alice) + h_bob = _auth_headers(client, "pub_bob") + + w1 = _make_world(client, h_alice, "AliceWorld") + asset_id = _make_asset(client, h_alice, "AliceAsset") + + client.put( + "/api/profile/me", + headers=h_alice, + json={"bio": "Hi, I am Alice", "avatar_asset_id": asset_id}, + ) + client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]}) + + resp = client.get(f"/api/profile/{alice_id}", headers=h_bob) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["user_id"] == alice_id + assert data["username"] == "pub_alice" + assert data["bio"] == "Hi, I am Alice" + assert data["avatar_asset_id"] == asset_id + assert data["is_restricted"] is False + assert len(data["favorites"]) == 1 + assert data["favorites"][0]["id"] == w1 + assert data["favorites"][0]["name"] == "AliceWorld" + assert "email" not in data + assert "password" not in data + + def test_public_profile_not_found(self): + client = TestClient(app) + h_bob = _auth_headers(client, "pub_nf_bob") + resp = client.get("/api/profile/999999", headers=h_bob) + assert resp.status_code == 404 + + def test_public_profile_requires_auth(self): + client = TestClient(app) + assert client.get("/api/profile/1").status_code == 401 + + def test_public_profile_private_visibility(self): + client = TestClient(app) + h_alice = _auth_headers(client, "pub_priv_a") + alice_id = _me_id(client, h_alice) + h_bob = _auth_headers(client, "pub_priv_b") + + client.put( + "/api/profile/me", + headers=h_alice, + json={"bio": "Top secret bio", "privacy": {"profile_visibility": "private"}}, + ) + + resp_bob = client.get(f"/api/profile/{alice_id}", headers=h_bob) + assert resp_bob.status_code == 200 + data_bob = resp_bob.json() + assert data_bob["is_restricted"] is True + assert data_bob["bio"] is None + assert data_bob["favorites"] == [] + assert data_bob["friends"] == [] + + # Alice viewing herself is not restricted + resp_alice = client.get(f"/api/profile/{alice_id}", headers=h_alice) + assert resp_alice.status_code == 200 + assert resp_alice.json()["is_restricted"] is False + assert resp_alice.json()["bio"] == "Top secret bio" + + def test_public_profile_friends_only_visibility(self): + client = TestClient(app) + h_alice = _auth_headers(client, "pub_fo_a") + alice_id = _me_id(client, h_alice) + h_bob = _auth_headers(client, "pub_fo_b") + bob_id = _me_id(client, h_bob) + h_charlie = _auth_headers(client, "pub_fo_c") + + client.put( + "/api/profile/me", + headers=h_alice, + json={"bio": "Friends only bio", "privacy": {"profile_visibility": "friends"}}, + ) + + # Charlie is not a friend -> restricted + resp_c = client.get(f"/api/profile/{alice_id}", headers=h_charlie) + assert resp_c.json()["is_restricted"] is True + assert resp_c.json()["bio"] is None + + # Bob becomes friends with Alice + req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id}) + req_id = req.json()["id"] + client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice) + + # Now Bob sees full profile and friends list contains Bob + resp_b = client.get(f"/api/profile/{alice_id}", headers=h_bob) + assert resp_b.json()["is_restricted"] is False + assert resp_b.json()["bio"] == "Friends only bio" + assert any(f["id"] == bob_id for f in resp_b.json()["friends"]) + + def test_public_profile_blocked_user_gets_restricted(self): + client = TestClient(app) + h_alice = _auth_headers(client, "pub_blk_a") + alice_id = _me_id(client, h_alice) + h_dan = _auth_headers(client, "pub_blk_d") + dan_id = _me_id(client, h_dan) + + client.put("/api/profile/me", headers=h_alice, json={"bio": "Alice public bio"}) + client.post(f"/api/friends/{dan_id}/block", headers=h_alice) + + # Dan viewing Alice gets restricted view + resp_dan = client.get(f"/api/profile/{alice_id}", headers=h_dan) + assert resp_dan.status_code == 200 + assert resp_dan.json()["is_restricted"] is True + assert resp_dan.json()["bio"] is None + + # Alice viewing Dan gets restricted view + resp_alice = client.get(f"/api/profile/{dan_id}", headers=h_alice) + assert resp_alice.status_code == 200 + assert resp_alice.json()["is_restricted"] is True + + def test_public_profile_selective_favorites_and_friends_privacy(self): + client = TestClient(app) + h_alice = _auth_headers(client, "pub_sec_a") + alice_id = _me_id(client, h_alice) + h_bob = _auth_headers(client, "pub_sec_b") + bob_id = _me_id(client, h_bob) + w1 = _make_world(client, h_alice, "WorldSec") + + client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]}) + req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id}) + req_id = req.json()["id"] + client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice) + + client.put( + "/api/profile/me", + headers=h_alice, + json={ + "bio": "Bio is public", + "privacy": { + "profile_visibility": "public", + "favorites_visibility": "private", + "friends_list_visibility": "private", + }, + }, + ) + + resp = client.get(f"/api/profile/{alice_id}", headers=h_bob) + assert resp.status_code == 200 + data = resp.json() + assert data["bio"] == "Bio is public" + assert data["favorites"] == [] + assert data["friends"] == [] +