Arkm20 commited on
Commit
1c3d615
·
verified ·
1 Parent(s): 3a04ae7

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +290 -0
app.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import uuid
4
+ from datetime import datetime, timedelta
5
+ from typing import List, Dict, Optional, Any
6
+
7
+ import secrets
8
+ from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form
9
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
10
+ from fastapi.staticfiles import StaticFiles
11
+ from fastapi.responses import FileResponse, RedirectResponse
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from jose import JWTError, jwt
14
+ from passlib.context import CryptContext
15
+ from pydantic import BaseModel, Field
16
+
17
+ # --- Configuration ---
18
+ # In a real production app, use Hugging Face Space Secrets to set this!
19
+ JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", secrets.token_hex(32))
20
+ ALGORITHM = "HS256"
21
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
22
+
23
+ # --- Persistent Data Paths (Hugging Face Spaces use /data for persistent storage) ---
24
+ DATA_DIR = "data"
25
+ USERS_DB_FILE = os.path.join(DATA_DIR, "users.json")
26
+ UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
27
+
28
+ # Create persistent directories if they don't exist
29
+ os.makedirs(DATA_DIR, exist_ok=True)
30
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
31
+
32
+ # --- Security & Hashing ---
33
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
34
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
35
+
36
+ # --- Pydantic Models (Data Schemas) ---
37
+ class Token(BaseModel):
38
+ access_token: str
39
+ token_type: str
40
+
41
+ class TokenData(BaseModel):
42
+ username: Optional[str] = None
43
+
44
+ class EpisodeUpdate(BaseModel):
45
+ show_id: str
46
+ show_title: str
47
+ season_number: int
48
+ episode_number: int
49
+
50
+ class UserBase(BaseModel):
51
+ username: str
52
+
53
+ class UserCreate(UserBase):
54
+ password: str
55
+
56
+ class UserInDB(UserBase):
57
+ hashed_password: str
58
+ profile_picture_url: Optional[str] = None
59
+ watch_history: List[Dict[str, Any]] = Field(default_factory=list)
60
+
61
+ class UserPublic(UserBase):
62
+ profile_picture_url: Optional[str] = None
63
+ watch_history_detailed: Dict[str, Any] = Field(default_factory=dict)
64
+
65
+
66
+ # --- Database Helper Functions (using JSON file) ---
67
+ def load_users() -> Dict[str, Dict]:
68
+ if not os.path.exists(USERS_DB_FILE):
69
+ return {}
70
+ with open(USERS_DB_FILE, "r") as f:
71
+ try:
72
+ return json.load(f)
73
+ except json.JSONDecodeError:
74
+ return {}
75
+
76
+ def save_users(users_db: Dict[str, Dict]):
77
+ with open(USERS_DB_FILE, "w") as f:
78
+ json.dump(users_db, f, indent=4)
79
+
80
+ # --- Password & Token Functions ---
81
+ def verify_password(plain_password, hashed_password):
82
+ return pwd_context.verify(plain_password, hashed_password)
83
+
84
+ def get_password_hash(password):
85
+ return pwd_context.hash(password)
86
+
87
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
88
+ to_encode = data.copy()
89
+ if expires_delta:
90
+ expire = datetime.utcnow() + expires_delta
91
+ else:
92
+ expire = datetime.utcnow() + timedelta(minutes=15)
93
+ to_encode.update({"exp": expire})
94
+ encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM)
95
+ return encoded_jwt
96
+
97
+ # --- Dependency to get current user ---
98
+ async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB:
99
+ credentials_exception = HTTPException(
100
+ status_code=status.HTTP_401_UNAUTHORIZED,
101
+ detail="Could not validate credentials",
102
+ headers={"WWW-Authenticate": "Bearer"},
103
+ )
104
+ try:
105
+ payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM])
106
+ username: str = payload.get("sub")
107
+ if username is None:
108
+ raise credentials_exception
109
+ token_data = TokenData(username=username)
110
+ except JWTError:
111
+ raise credentials_exception
112
+
113
+ users_db = load_users()
114
+ user_data = users_db.get(token_data.username)
115
+ if user_data is None:
116
+ raise credentials_exception
117
+
118
+ return UserInDB(**user_data)
119
+
120
+
121
+ # --- FastAPI App Initialization ---
122
+ app = FastAPI(title="Media Auth API")
123
+
124
+ # CORS middleware to allow the frontend to communicate with the API
125
+ # This is crucial for web apps.
126
+ app.add_middleware(
127
+ CORSMiddleware,
128
+ allow_origins=["*"], # Allows all origins
129
+ allow_credentials=True,
130
+ allow_methods=["*"], # Allows all methods
131
+ allow_headers=["*"], # Allows all headers
132
+ )
133
+
134
+ # --- Helper function to structure watch history for the frontend ---
135
+ def structure_watch_history(history_list: List[Dict]) -> Dict:
136
+ """Transforms a flat list of watched episodes into a nested dictionary."""
137
+ structured = {}
138
+ for item in history_list:
139
+ show_id = item.get("show_id")
140
+ show_title = item.get("show_title", "Unknown Show")
141
+ season_num = item.get("season_number")
142
+ episode_num = item.get("episode_number")
143
+
144
+ if show_id not in structured:
145
+ structured[show_id] = {
146
+ "show_id": show_id,
147
+ "title": show_title,
148
+ "seasons": {}
149
+ }
150
+ if season_num not in structured[show_id]["seasons"]:
151
+ structured[show_id]["seasons"][season_num] = {
152
+ "season_number": season_num,
153
+ "episodes": {}
154
+ }
155
+ structured[show_id]["seasons"][season_num]["episodes"][episode_num] = True
156
+ return structured
157
+
158
+
159
+ # --- API Endpoints ---
160
+
161
+ @app.post("/token", response_model=Token, tags=["Authentication"])
162
+ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
163
+ """
164
+ Standard OAuth2 login. Takes username and password from a form.
165
+ """
166
+ users_db = load_users()
167
+ user_data = users_db.get(form_data.username)
168
+ if not user_data or not verify_password(form_data.password, user_data["hashed_password"]):
169
+ raise HTTPException(
170
+ status_code=status.HTTP_401_UNAUTHORIZED,
171
+ detail="Incorrect username or password",
172
+ headers={"WWW-Authenticate": "Bearer"},
173
+ )
174
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
175
+ access_token = create_access_token(
176
+ data={"sub": user_data["username"]}, expires_delta=access_token_expires
177
+ )
178
+ return {"access_token": access_token, "token_type": "bearer"}
179
+
180
+
181
+ @app.post("/signup", status_code=status.HTTP_201_CREATED, tags=["Authentication"])
182
+ async def signup_user(user: UserCreate):
183
+ """
184
+ Creates a new user account.
185
+ """
186
+ users_db = load_users()
187
+ if user.username in users_db:
188
+ raise HTTPException(
189
+ status_code=status.HTTP_400_BAD_REQUEST,
190
+ detail="Username already registered",
191
+ )
192
+ hashed_password = get_password_hash(user.password)
193
+ new_user = UserInDB(username=user.username, hashed_password=hashed_password)
194
+ users_db[user.username] = new_user.dict()
195
+ save_users(users_db)
196
+ return {"message": "User created successfully. Please login."}
197
+
198
+
199
+ @app.get("/users/me", response_model=UserPublic, tags=["User"])
200
+ async def read_users_me(current_user: UserInDB = Depends(get_current_user)):
201
+ """
202
+ Fetch the profile of the currently authenticated user.
203
+ """
204
+ # The frontend expects a detailed, nested history object. We transform it here.
205
+ detailed_history = structure_watch_history(current_user.watch_history)
206
+
207
+ # We create a UserPublic object to avoid sending the hashed_password
208
+ user_public_data = UserPublic(
209
+ username=current_user.username,
210
+ profile_picture_url=current_user.profile_picture_url,
211
+ watch_history_detailed=detailed_history
212
+ )
213
+ return user_public_data
214
+
215
+
216
+ @app.post("/users/me/profile-picture", response_model=UserPublic, tags=["User"])
217
+ async def upload_profile_picture(
218
+ file: UploadFile = File(...),
219
+ current_user: UserInDB = Depends(get_current_user)
220
+ ):
221
+ """
222
+ Upload or update the user's profile picture.
223
+ """
224
+ # Generate a unique filename to prevent overwrites and add extension
225
+ file_extension = os.path.splitext(file.filename)[1]
226
+ unique_filename = f"{uuid.uuid4()}{file_extension}"
227
+ file_path = os.path.join(UPLOAD_DIR, unique_filename)
228
+
229
+ # Save the file
230
+ with open(file_path, "wb") as buffer:
231
+ buffer.write(await file.read())
232
+
233
+ # Update user data with the URL to the new picture
234
+ # The URL must match the static file mount path
235
+ profile_picture_url = f"/uploads/{unique_filename}"
236
+ users_db = load_users()
237
+ users_db[current_user.username]["profile_picture_url"] = profile_picture_url
238
+ save_users(users_db)
239
+
240
+ # Return the updated user profile
241
+ current_user.profile_picture_url = profile_picture_url
242
+ detailed_history = structure_watch_history(current_user.watch_history)
243
+ return UserPublic(
244
+ username=current_user.username,
245
+ profile_picture_url=current_user.profile_picture_url,
246
+ watch_history_detailed=detailed_history
247
+ )
248
+
249
+
250
+ @app.post("/users/me/watch-history", status_code=status.HTTP_200_OK, tags=["User"])
251
+ async def update_watch_history(
252
+ episode_data: EpisodeUpdate,
253
+ current_user: UserInDB = Depends(get_current_user)
254
+ ):
255
+ """
256
+ Adds a new episode to the user's watch history.
257
+ """
258
+ users_db = load_users()
259
+ user_data = users_db[current_user.username]
260
+
261
+ # Create a unique identifier for the episode to prevent duplicates
262
+ episode_id = f"{episode_data.show_id}_{episode_data.season_number}_{episode_data.episode_number}"
263
+
264
+ # Check if this exact episode is already in the history
265
+ is_already_watched = any(
266
+ (f"{item.get('show_id')}_{item.get('season_number')}_{item.get('episode_number')}" == episode_id)
267
+ for item in user_data["watch_history"]
268
+ )
269
+
270
+ if not is_already_watched:
271
+ user_data["watch_history"].append(episode_data.dict())
272
+ save_users(users_db)
273
+ return {"message": "Watch history updated."}
274
+
275
+ return {"message": "Episode already in watch history."}
276
+
277
+
278
+ # --- Static File Serving ---
279
+ # This serves the uploaded profile pictures from the persistent /data/uploads directory
280
+ app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
281
+
282
+ # This serves the main login/settings pages from the /static directory
283
+ # 'html=True' makes it so that visiting "/" serves "index.html" (we'll name login.html as index.html)
284
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
285
+
286
+
287
+ # Redirect root to login page for convenience
288
+ @app.get("/", include_in_schema=False)
289
+ def root():
290
+ return RedirectResponse(url="/login.html")