Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pip-delete-this-directory.txt
.env.test.local
.env.production.local
*.env
backend/.env

# IDE
.vscode/
Expand Down
1 change: 1 addition & 0 deletions backend/.cache
Original file line number Diff line number Diff line change
@@ -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}
290 changes: 286 additions & 4 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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"
)
status="connected", message="Backend is operational", service="FastAPI Backend"
)
Original file line number Diff line number Diff line change
@@ -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')
4 changes: 3 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
httpx==0.27.2
cryptography==42.0.5
spotipy==2.24.0
Loading