Init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -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"]
|
||||||
14
Makefile
Normal file
14
Makefile
Normal file
@@ -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)
|
||||||
27
app/firebase_config.py
Normal file
27
app/firebase_config.py
Normal file
@@ -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()
|
||||||
15
app/main.py
Normal file
15
app/main.py
Normal file
@@ -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!"}
|
||||||
40
app/models.py
Normal file
40
app/models.py
Normal file
@@ -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
|
||||||
73
app/routers/auth.py
Normal file
73
app/routers/auth.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
47
app/routers/users.py
Normal file
47
app/routers/users.py
Normal file
@@ -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")
|
||||||
116
app/routers/videos.py
Normal file
116
app/routers/videos.py
Normal file
@@ -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]
|
||||||
54
app/security.py
Normal file
54
app/security.py
Normal file
@@ -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
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pydantic
|
||||||
|
firebase-admin
|
||||||
|
python-dotenv
|
||||||
|
pydantic[email]
|
||||||
|
httpx
|
||||||
Reference in New Issue
Block a user