diff --git a/.gitignore b/.gitignore index 7b4ff1d..9bb7e19 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ pip-delete-this-directory.txt .env.test.local .env.production.local *.env +backend/.env # IDE .vscode/ diff --git a/backend/.cache b/backend/.cache new file mode 100644 index 0000000..5cdad5d --- /dev/null +++ b/backend/.cache @@ -0,0 +1 @@ +{"access_token": "BQBT1UE-NpeR4NzVkD6H7tm9QiR0vE8O9AZQDznGWkcEpEuzMMZk1JTSo74ROAFy_dAkLT9AYyAJaCaHafzG0ZqSwNg7oKA8wHAByJHs_8mwqAfWps5hGv0ZXPFZkH6Vc9ghtoYU2GJ2nQftkfsLkpQ9pliL8mrBFe6OavFZhiYd_bI3dReFkfBFW1OT0nomZ7HpbQnsywbf_0jL8UaAqu9bWr8fon3c78B5GblZBHrsrYRazyJz6yLQA3BViJWbXXRuecJSoibZxhl7c4XDgRXVWSnalQZ6Gxk2XGCJCgM", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "AQBxlxKHD2wVFtc9HuG6ovA2pzKRzBht3SOVAXXiGpYLybj5rOwCOseFM8M_7Rj5GFo96OSX81tAz4JMp9AS7zEC70VYzNNDRPiTip1zz5kmo9vIi-GxgYJM3nDnQGaoWw4", "scope": "playlist-modify-public playlist-modify-private", "expires_at": 1763646169} \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 01eee81..96904b2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,26 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Dict +import os +import secrets +from urllib.parse import urlencode +from fastapi import HTTPException, Request +from fastapi.responses import RedirectResponse +from cryptography.fernet import Fernet +import spotipy +from spotipy.oauth2 import SpotifyOAuth +import redis +import sqlalchemy +from sqlalchemy import ( + create_engine, + Table, + Column, + Integer, + String, + Text, + TIMESTAMP, + MetaData, +) app = FastAPI(title="Backend API", version="1.0.0") @@ -13,19 +33,281 @@ allow_headers=["*"], ) +# Environment variables +SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") +SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") +SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI") +ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY") +DATABASE_URL = os.getenv("DATABASE_URL") +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379") + +# Initialize Fernet encryption +cipher = Fernet(ENCRYPTION_KEY.encode()) + +# Redis connection for state management +redis_client = redis.from_url(REDIS_URL, decode_responses=True) + +# Database setup +engine = create_engine(DATABASE_URL) +metadata = MetaData() + +# Define spotify_connections table +spotify_connections = Table( + "spotify_connections", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", String(128), unique=True, nullable=False), + Column("encrypted_refresh_token", Text, nullable=False), + Column("spotify_user_id", String(128)), + Column("connected_at", TIMESTAMP), + Column("updated_at", TIMESTAMP), +) + +# Helper Functions + + +def encrypt_token(token: str) -> str: + """Encrypt a token using Fernet""" + return cipher.encrypt(token.encode()).decode() + + +def decrypt_token(encrypted_token: str) -> str: + """Decrypt a token using Fernet""" + return cipher.decrypt(encrypted_token.encode()).decode() + + +def generate_state() -> str: + """Generate a random state parameter for CSRF protection""" + return secrets.token_urlsafe(32) + + +def save_state(user_id: str, state: str): + """Save state to Redis with 10 minute expiration""" + # Store state -> user_id mapping (so we can look it up later) + redis_client.setex(f"spotify_state:{state}", 600, user_id) + + +def verify_and_get_user(state: str) -> str: + """Verify state parameter and return user_id""" + user_id = redis_client.get(f"spotify_state:{state}") + if user_id: + redis_client.delete(f"spotify_state:{state}") # Clean up after verification + return user_id + return None + + +# Spotify OAuth Endpoints + + +@app.get("/api/spotify/auth") +async def spotify_auth(user_id: str): + """ + Initiates Spotify OAuth flow + + Args: + user_id: The Firebase UID of the user connecting their Spotify account + + Returns: + Redirects user to Spotify authorization page + """ + # Generate and save state for CSRF protection + state = generate_state() + save_state(user_id, state) + + # Define the permissions we need from Spotify + scopes = "playlist-modify-public playlist-modify-private" + + # Build the Spotify authorization URL + auth_params = { + "client_id": SPOTIFY_CLIENT_ID, + "response_type": "code", + "redirect_uri": SPOTIFY_REDIRECT_URI, + "scope": scopes, + "state": state, + } + + auth_url = f"https://accounts.spotify.com/authorize?{urlencode(auth_params)}" + + # Redirect user to Spotify + return RedirectResponse(url=auth_url) + + +@app.get("/api/spotify/callback") +async def spotify_callback(code: str, state: str, error: str = None): + """ + Handles Spotify OAuth callback + + Args: + code: Authorization code from Spotify + state: State parameter for CSRF verification + error: Error from Spotify (if user denied) + + Returns: + Success or error message + """ + # Handle user denial + if error: + raise HTTPException( + status_code=400, detail=f"Spotify authorization failed: {error}" + ) + + # Verify state and get user_id from Redis + user_id = verify_and_get_user(state) + if not user_id: + raise HTTPException( + status_code=400, detail="Invalid state parameter - possible CSRF attack" + ) + + try: + # Exchange authorization code for tokens + sp_oauth = SpotifyOAuth( + client_id=SPOTIFY_CLIENT_ID, + client_secret=SPOTIFY_CLIENT_SECRET, + redirect_uri=SPOTIFY_REDIRECT_URI, + scope="playlist-modify-public playlist-modify-private", + ) + + token_info = sp_oauth.get_access_token(code, as_dict=True, check_cache=False) + + if not token_info: + raise HTTPException( + status_code=400, detail="Failed to get access token from Spotify" + ) + + refresh_token = token_info["refresh_token"] + access_token = token_info["access_token"] + + # Get Spotify user ID + sp = spotipy.Spotify(auth=access_token) + spotify_user = sp.current_user() + spotify_user_id = spotify_user["id"] + + # Encrypt the refresh token + encrypted_token = encrypt_token(refresh_token) + + # Save to database (upsert - overwrite if exists) + with engine.connect() as conn: + # Check if connection already exists + select_stmt = spotify_connections.select().where( + spotify_connections.c.user_id == user_id + ) + existing = conn.execute(select_stmt).fetchone() + + if existing: + # Update existing connection + update_stmt = ( + spotify_connections.update() + .where(spotify_connections.c.user_id == user_id) + .values( + encrypted_refresh_token=encrypted_token, + spotify_user_id=spotify_user_id, + updated_at=sqlalchemy.func.now(), + ) + ) + conn.execute(update_stmt) + else: + # Insert new connection + insert_stmt = spotify_connections.insert().values( + user_id=user_id, + encrypted_refresh_token=encrypted_token, + spotify_user_id=spotify_user_id, + ) + conn.execute(insert_stmt) + + conn.commit() + + return { + "message": "Spotify connected successfully", + "spotify_user_id": spotify_user_id, + } + + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error connecting to Spotify: {str(e)}" + ) from e + + +@app.get("/api/spotify/status") +async def spotify_status(user_id: str): + """ + Check if user has connected their Spotify account + + Args: + user_id: The Firebase UID of the user + + Returns: + Connection status and Spotify user ID if connected + """ + try: + with engine.connect() as conn: + select_stmt = spotify_connections.select().where( + spotify_connections.c.user_id == user_id + ) + result = conn.execute(select_stmt).fetchone() + + if result: + return { + "connected": True, + "spotify_user_id": result.spotify_user_id, + "connected_at": str(result.connected_at), + } + else: + return {"connected": False} + + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error checking Spotify status: {str(e)}" + ) from e + + +@app.delete("/api/spotify/disconnect") +async def spotify_disconnect(user_id: str): + """ + Disconnect user's Spotify account + + Args: + user_id: The Firebase UID of the user + + Returns: + Success message + """ + try: + with engine.connect() as conn: + # Delete the connection + delete_stmt = spotify_connections.delete().where( + spotify_connections.c.user_id == user_id + ) + result = conn.execute(delete_stmt) + conn.commit() + + if result.rowcount > 0: + return {"message": "Spotify disconnected successfully"} + else: + raise HTTPException( + status_code=404, detail="No Spotify connection found for this user" + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error disconnecting Spotify: {str(e)}" + ) from e + + class HealthResponse(BaseModel): status: str message: str service: str + @app.get("/") async def root() -> Dict[str, str]: return {"message": "Backend is running!"} + @app.get("/api/health", response_model=HealthResponse) async def health_check() -> HealthResponse: return HealthResponse( - status="connected", - message="Backend is operational", - service="FastAPI Backend" - ) \ No newline at end of file + status="connected", message="Backend is operational", service="FastAPI Backend" + ) diff --git a/backend/migrations/versions/d3d6086773c5_create_spotify_connections_table.py b/backend/migrations/versions/d3d6086773c5_create_spotify_connections_table.py new file mode 100644 index 0000000..2cb3cbc --- /dev/null +++ b/backend/migrations/versions/d3d6086773c5_create_spotify_connections_table.py @@ -0,0 +1,40 @@ +"""create + spotify_connections table + +Revision ID: d3d6086773c5 +Revises: a192f6dcadde +Create Date: 2025-11-09 22:52:26.083801 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd3d6086773c5' +down_revision: Union[str, None] = 'a192f6dcadde' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'spotify_connections', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.String(length=128), nullable=False), + sa.Column('encrypted_refresh_token', sa.Text(), nullable=False), + sa.Column('spotify_user_id', sa.String(length=128), + nullable=True), + sa.Column('connected_at', sa.TIMESTAMP(), + server_default=sa.text('NOW()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), + server_default=sa.text('NOW()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + + +def downgrade() -> None: + op.drop_table('spotify_connections') diff --git a/backend/requirements.txt b/backend/requirements.txt index 3e27262..ec9c27d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,6 @@ asyncpg==0.30.0 sqlalchemy==2.0.35 alembic==1.14.0 psycopg2-binary==2.9.10 -httpx==0.27.2 \ No newline at end of file +httpx==0.27.2 +cryptography==42.0.5 +spotipy==2.24.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0e8abaf..8eb98f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: context: ./frontend dockerfile: Dockerfile ports: - - "3000:3000" + - '3000:3000' environment: - NODE_ENV=development - EXPO_PUBLIC_API_URL=http://localhost:8000 @@ -21,10 +21,10 @@ services: context: ./backend dockerfile: Dockerfile ports: - - "8000:8000" + - '8000:8000' + env_file: + - ./backend/.env environment: - - DATABASE_URL=postgresql://app_user:app_password@postgres:5432/app_db - - REDIS_URL=redis://redis:6379 - PYTHONUNBUFFERED=1 volumes: - ./backend:/app @@ -35,7 +35,7 @@ services: postgres: image: postgres:17-alpine ports: - - "5432:5432" + - '5432:5432' environment: - POSTGRES_USER=app_user - POSTGRES_PASSWORD=app_password @@ -46,10 +46,10 @@ services: redis: image: redis:7-alpine ports: - - "6379:6379" + - '6379:6379' volumes: - redis_data:/data volumes: postgres_data: - redis_data: \ No newline at end of file + redis_data: