commit 80b48c00dea11ae36724feff71b11b269dc83299 Author: Logan Date: Sun Jul 13 12:56:16 2025 -0400 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c842e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..29b42fb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Start with an official Python runtime as a parent image +FROM python:3.13-slim + +# Set the working directory in the container +WORKDIR /code + +# Copy the requirements file into the container +COPY ./requirements.txt /code/requirements.txt + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt + +# Copy the application code into the container +COPY ./app /code/app +COPY ./.env /code/.env + +# Expose port 80 to the outside world +EXPOSE 80 + +# Command to run the application using uvicorn +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..34dacc9 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +IMAGE_NAME = twimg-app +CONTAINER_NAME = twimg-app + +# Target to build the Docker image for sdr-node +build: + docker build -t $(IMAGE_NAME) . + +# Target to run the sdr-node container +run: build + docker run --rm -it \ + -v "S:\TWIMG-Eligible":/app \ + --name $(CONTAINER_NAME) \ + -p 8000:8000 \ + $(IMAGE_NAME) \ No newline at end of file diff --git a/app/firebase_config.py b/app/firebase_config.py new file mode 100644 index 0000000..8bf78b2 --- /dev/null +++ b/app/firebase_config.py @@ -0,0 +1,27 @@ +import firebase_admin +from firebase_admin import credentials, auth, firestore +import os +from dotenv import load_dotenv + +load_dotenv() + +# Securely load Firebase credentials from environment variables +cred = credentials.Certificate({ + "type": os.environ.get("FIREBASE_TYPE"), + "project_id": os.environ.get("FIREBASE_PROJECT_ID"), + "private_key_id": os.environ.get("FIREBASE_PRIVATE_KEY_ID"), + "private_key": os.environ.get("FIREBASE_PRIVATE_KEY").replace('\\n', '\n'), + "client_email": os.environ.get("FIREBASE_CLIENT_EMAIL"), + "client_id": os.environ.get("FIREBASE_CLIENT_ID"), + "auth_uri": os.environ.get("FIREBASE_AUTH_URI"), + "token_uri": os.environ.get("FIREBASE_TOKEN_URI"), + "auth_provider_x509_cert_url": os.environ.get("FIREBASE_AUTH_PROVIDER_X509_CERT_URL"), + "client_x509_cert_url": os.environ.get("FIREBASE_CLIENT_X509_CERT_URL") +}) + +# Initialize the Firebase app +firebase_admin.initialize_app(cred) + +def get_db(): + """Returns a Firestore client instance.""" + return firestore.client() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d0202e7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI +from .routers import auth, users, videos + +app = FastAPI(title="Video Voting App") + +app.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +app.include_router(users.router, prefix="/users", tags=["Users"]) +app.include_router(videos.router, prefix="/videos", tags=["Videos"]) + +@app.get("/", tags=["Root"]) +async def read_root(): + """ + The root endpoint of the Video Voting API. + """ + return {"message": "Welcome to the Video Voting API!"} \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..6804f22 --- /dev/null +++ b/app/models.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel, Field, EmailStr +from typing import Optional, List + +class LoginRequest(BaseModel): + id_token: str + +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(..., min_length=6) + full_name: str = Field(..., min_length=3) + +class UserRecord(BaseModel): + uid: str + email: EmailStr + full_name: str + role: str = "member" + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + +class Video(BaseModel): + id: str + file_path: str + person: str + game: Optional[str] = None + has_been_voted: bool = False + +class VoteCreate(BaseModel): + decision: str + reason: str + recommended_game: Optional[str] = None + +class Vote(BaseModel): + id: str + video_id: str + user_id: str + decision: str + reason: str + recommended_game: Optional[str] = None \ No newline at end of file diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..3894063 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from firebase_admin import auth +from ..firebase_config import get_db +from ..models import UserCreate, UserRecord, LoginRequest + +router = APIRouter() + +@router.post("/register", response_model=UserRecord, status_code=status.HTTP_201_CREATED) +async def register_user(user: UserCreate): + """ + Registers a user in Firebase Auth and creates a corresponding user document in Firestore. + """ + try: + user_record = auth.create_user( + email=user.email, + password=user.password, + display_name=user.full_name + ) + + db = get_db() + user_data = { + "uid": user_record.uid, + "email": user.email, + "full_name": user.full_name, + "role": "member" + } + db.collection('users').document(user_record.uid).set(user_data) + + return user_data + + except auth.EmailAlreadyExistsError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + +@router.post("/login", response_model=UserRecord) +async def login(login_data: LoginRequest): + """ + Verifies a Firebase ID token, checks user role from Firestore, and returns user data. + """ + try: + decoded_token = auth.verify_id_token(login_data.id_token) + uid = decoded_token['uid'] + + db = get_db() + user_doc = db.collection('users').document(uid).get() + + if not user_doc.exists: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found in the database.", + ) + + user = user_doc.to_dict() + if user.get("role") == "member": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is not activated. Please contact an administrator." + ) + + return UserRecord(**user) + + except auth.InvalidIdTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Firebase ID token", + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) \ No newline at end of file diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..22da7b1 --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from typing import List +from ..models import UserRecord +from ..security import is_admin +from ..firebase_config import get_db, auth + +router = APIRouter() + +@router.get("/", response_model=List[UserRecord], dependencies=[Depends(is_admin)]) +async def read_users(): + """ + Retrieves a list of all user documents from the 'users' collection in Firestore. + """ + db = get_db() + users_ref = db.collection('users').stream() + return [UserRecord(**doc.to_dict()) for doc in users_ref] + +@router.patch("/{user_id}/role", response_model=UserRecord, dependencies=[Depends(is_admin)]) +async def update_user_role(user_id: str, role: str): + """ + Updates a user's role in their Firestore document. + """ + if role not in ["member", "user", "admin"]: + raise HTTPException(status_code=400, detail="Invalid role specified") + + db = get_db() + user_ref = db.collection('users').document(user_id) + if user_ref.get().exists: + user_ref.update({"role": role}) + updated_user = user_ref.get() + return UserRecord(**updated_user.to_dict()) + raise HTTPException(status_code=404, detail="User not found") + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(is_admin)]) +async def delete_user(user_id: str): + """ + Deletes a user from Firebase Auth and their corresponding document from Firestore. + """ + try: + auth.delete_user(user_id) + db = get_db() + user_ref = db.collection('users').document(user_id) + if user_ref.get().exists: + user_ref.delete() + return + except auth.UserNotFoundError: + raise HTTPException(status_code=404, detail="User not found in Firebase Authentication") \ No newline at end of file diff --git a/app/routers/videos.py b/app/routers/videos.py new file mode 100644 index 0000000..a037dbd --- /dev/null +++ b/app/routers/videos.py @@ -0,0 +1,116 @@ +import os +import random +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import FileResponse +from typing import List +from ..models import Video, Vote, VoteCreate +from ..security import is_user, is_admin +from ..firebase_config import get_db +from dotenv import load_dotenv + +load_dotenv() +router = APIRouter() +VIDEO_DIRECTORY = os.environ.get("VIDEO_DIRECTORY") + +@router.post("/scan", status_code=status.HTTP_201_CREATED, dependencies=[Depends(is_admin)]) +async def scan_videos_directory(): + """ + Scans the video directory and adds new video documents to the 'videos' collection in Firestore. + """ + new_videos_count = 0 + if not os.path.isdir(VIDEO_DIRECTORY): + raise HTTPException(status_code=500, detail="Video directory not found on server") + + db = get_db() + videos_ref = db.collection('videos') + + for person_name in os.listdir(VIDEO_DIRECTORY): + # ... (directory scanning logic remains the same) ... + # Simplified for brevity; assuming inner loop logic here + file_path = os.path.join(person_name, "some_clip.mp4") # Placeholder + + # Check if video already exists by file_path + existing_videos = videos_ref.where('file_path', '==', file_path).limit(1).get() + if not existing_videos: + new_video_ref = videos_ref.document() + video_data = { + "id": new_video_ref.id, "file_path": file_path, "person": person_name, + "game": None, "has_been_voted": False + } + new_video_ref.set(video_data) + new_videos_count += 1 + + return {"message": f"Scan complete. Added {new_videos_count} new videos."} + + +@router.get("/vote-next", response_model=Video, dependencies=[Depends(is_user)]) +async def get_random_unvoted_video(): + """ + Retrieves a random, unvoted video document from Firestore. + """ + db = get_db() + videos_stream = db.collection('videos').where('has_been_voted', '==', False).stream() + unvoted_videos = [doc.to_dict() for doc in videos_stream] + + if not unvoted_videos: + raise HTTPException(status_code=404, detail="No more videos to vote on!") + + random_video_data = random.choice(unvoted_videos) + return Video(**random_video_data) + + +@router.post("/{video_id}/vote", status_code=status.HTTP_201_CREATED) +async def submit_vote(video_id: str, vote_data: VoteCreate, current_user: dict = Depends(is_user)): + """ + Submits a vote, creating a 'vote' document and updating the video document in Firestore. + """ + db = get_db() + video_ref = db.collection('videos').document(video_id) + video_doc = video_ref.get() + + if not video_doc.exists: + raise HTTPException(status_code=404, detail="Video not found") + if video_doc.to_dict().get("has_been_voted"): + raise HTTPException(status_code=400, detail="This video has already been voted on") + + new_vote_ref = db.collection('votes').document() + new_vote_ref.set({ + "id": new_vote_ref.id, "video_id": video_id, "user_id": current_user['uid'], + "decision": vote_data.decision, "reason": vote_data.reason, + "recommended_game": vote_data.recommended_game + }) + + update_data = {"has_been_voted": True} + if vote_data.recommended_game: + update_data["game"] = vote_data.recommended_game + video_ref.update(update_data) + + return {"message": "Vote submitted successfully"} + + +@router.get("/{video_id}/stream", dependencies=[Depends(is_user)]) +async def stream_video(video_id: str): + """ + Streams a video file from the server based on its Firestore document. + """ + db = get_db() + video_doc = db.collection('videos').document(video_id).get() + + if not video_doc.exists: + raise HTTPException(status_code=404, detail="Video not found") + + full_path = os.path.join(VIDEO_DIRECTORY, video_doc.to_dict()["file_path"]) + if not os.path.exists(full_path): + raise HTTPException(status_code=404, detail="Video file not found on disk") + + return FileResponse(full_path, media_type="video/mp4") + + +@router.get("/votes", response_model=List[Vote], dependencies=[Depends(is_admin)]) +async def get_all_votes(): + """ + Admin endpoint to retrieve all vote documents from Firestore. + """ + db = get_db() + votes_stream = db.collection('votes').stream() + return [Vote(**doc.to_dict()) for doc in votes_stream] \ No newline at end of file diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..05527b4 --- /dev/null +++ b/app/security.py @@ -0,0 +1,54 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from firebase_admin import auth +from .firebase_config import get_db + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") + +async def get_current_user(token: str = Depends(oauth2_scheme)): + """ + Verifies the Firebase ID token and retrieves the user document from Firestore. + """ + try: + decoded_token = auth.verify_id_token(token) + uid = decoded_token['uid'] + + db = get_db() + user_doc = db.collection('users').document(uid).get() + + if not user_doc.exists: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found in Firestore", + ) + + user_data = user_doc.to_dict() + return user_data + + except auth.InvalidIdTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Firebase ID token", + headers={"WWW-Authenticate": "Bearer"}, + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"An error occurred: {e}", + ) + +def is_user(current_user: dict = Depends(get_current_user)): + """ + Dependency to ensure the current user has 'user' or 'admin' role. + """ + if current_user.get('role') not in ["user", "admin"]: + raise HTTPException(status_code=403, detail="Not enough permissions") + return current_user + +def is_admin(current_user: dict = Depends(get_current_user)): + """ + Dependency to ensure the current user has 'admin' role. + """ + if current_user.get('role') != "admin": + raise HTTPException(status_code=403, detail="Requires admin role") + return current_user \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..110a25b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn +pydantic +firebase-admin +python-dotenv +pydantic[email] +httpx \ No newline at end of file