Skip to content

Commit ddb249a

Browse files
authored
Merge pull request #67 from cuappdev/master
Merge dev to prod March 19
2 parents 401045d + c57a11d commit ddb249a

17 files changed

Lines changed: 278 additions & 12 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ __pycache__/
44
.env
55
.envrc
66
.DS_Store
7-
ca-certificate.crt
7+
ca-certificate.crt
8+
firebase-service-account-key.json

app.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,47 @@
11
import logging
22
import argparse
3-
from flask import Flask, request, g
3+
import signal
4+
import sys
45
import time
6+
from datetime import datetime, timedelta
7+
8+
from dotenv import load_dotenv
9+
10+
load_dotenv()
11+
12+
from flask import Flask, jsonify, request, g
13+
from flask_cors import CORS
14+
from flask_jwt_extended import JWTManager
515
from flask_graphql import GraphQLView
616
from graphene import Schema
717
from src.schema import Query, Mutation
818
from src.scrapers.games_scraper import fetch_game_schedule
919
from src.scrapers.youtube_stats import fetch_videos
1020
from src.scrapers.daily_sun_scrape import fetch_news
1121
from src.services.article_service import ArticleService
22+
from src.utils.constants import JWT_SECRET_KEY
1223
from src.utils.team_loader import TeamLoader
13-
import signal
14-
import sys
15-
from dotenv import load_dotenv
16-
17-
load_dotenv()
24+
from src.database import db, client
1825

1926
app = Flask(__name__)
2027

28+
# CORS: allow frontend (different origin) to call this API
29+
CORS(app, supports_credentials=True)
30+
31+
# JWT config
32+
app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY
33+
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
34+
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
35+
36+
jwt = JWTManager(app)
37+
38+
39+
@jwt.token_in_blocklist_loader
40+
def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool:
41+
"""Reject the request if the token's jti is in the blocklist (e.g. after logout)."""
42+
jti = jwt_payload["jti"]
43+
return db["token_blocklist"].find_one({"jti": jti}) is not None
44+
2145

2246
@app.before_request
2347
def start_timer():
@@ -73,13 +97,22 @@ def log_response_time(response):
7397
datefmt="%Y-%m-%d %H:%M:%S",
7498
)
7599

76-
schema = Schema(query=Query, mutation=Mutation)
100+
schema = Schema(query=Query, mutation=Mutation, auto_camelcase=True)
77101

78102

79103
def create_context():
80104
return {"team_loader": TeamLoader()}
81105

82106

107+
@app.route("/health")
108+
def health_check():
109+
try:
110+
client.admin.command("ping")
111+
return jsonify({"status": "healthy", "database": "connected"}), 200
112+
except Exception:
113+
return jsonify({"status": "unhealthy", "database": "disconnected"}), 503
114+
115+
83116
app.add_url_rule(
84117
"/graphql",
85118
view_func=GraphQLView.as_view(
@@ -136,6 +169,17 @@ class DefaultArgs:
136169
scheduler.init_app(app)
137170
scheduler.start()
138171

172+
@scheduler.task("interval", id="cleanse_token_blocklist", seconds=86400) # 24 hours
173+
def cleanse_token_blocklist():
174+
"""Remove expired tokens from blocklist so the collection doesn't grow forever."""
175+
from datetime import timezone
176+
from src.database import db
177+
result = db["token_blocklist"].delete_many(
178+
{"expires_at": {"$lt": datetime.now(timezone.utc)}}
179+
)
180+
if result.deleted_count:
181+
logging.info(f"Cleansed {result.deleted_count} expired token(s) from blocklist")
182+
139183
@scheduler.task("interval", id="scrape_schedules", seconds=43200) # 12 hours
140184
def scrape_schedules():
141185
logging.info("Scraping game schedules...")

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
Flask
2+
Flask-CORS
3+
Flask-JWT-Extended==4.7.1
24
Flask-GraphQL
35
graphene
46
pymongo

src/database.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ def setup_database_indexes():
9898
background=True
9999
)
100100

101+
# JWT blocklist: fast lookup by jti
102+
db["token_blocklist"].create_index([("jti", 1)], background=True)
103+
101104
print("✅ MongoDB indexes created successfully")
102105
except Exception as e:
103106
print(f"❌ Failed to create MongoDB indexes: {e}")

src/mutations/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
from .create_game import CreateGame
22
from .create_team import CreateTeam
33
from .create_youtube_video import CreateYoutubeVideo
4-
from .create_article import CreateArticle
4+
from .create_article import CreateArticle
5+
from .login_user import LoginUser
6+
from .signup_user import SignupUser
7+
from .refresh_access_token import RefreshAccessToken
8+
from .logout_user import LogoutUser
9+
from .add_favorite_game import AddFavoriteGame
10+
from .remove_favorite_game import RemoveFavoriteGame

src/mutations/add_favorite_game.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from bson import ObjectId
2+
from graphql import GraphQLError
3+
from graphene import Mutation, String, Boolean
4+
5+
from flask_jwt_extended import get_jwt_identity, jwt_required
6+
from src.database import db
7+
from src.services.game_service import GameService
8+
9+
10+
class AddFavoriteGame(Mutation):
11+
class Arguments:
12+
game_id = String(required=True, description="ID of the game to add to favorites.")
13+
14+
success = Boolean()
15+
16+
@jwt_required()
17+
def mutate(self, info, game_id):
18+
if not GameService.get_game_by_id(game_id):
19+
raise GraphQLError("Game not found.")
20+
user_id = get_jwt_identity()
21+
db["users"].update_one(
22+
{"_id": ObjectId(user_id)},
23+
{"$addToSet": {"favorite_game_ids": game_id}},
24+
)
25+
return AddFavoriteGame(success=True)

src/mutations/login_user.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from graphql import GraphQLError
2+
from graphene import Mutation, String, Field
3+
4+
from flask_jwt_extended import create_access_token, create_refresh_token
5+
from src.database import db
6+
7+
8+
class LoginUser(Mutation):
9+
class Arguments:
10+
net_id = String(required=True, description="User's net ID (e.g. Cornell netid).")
11+
12+
access_token = String()
13+
refresh_token = String()
14+
15+
def mutate(self, info, net_id):
16+
user = db["users"].find_one({"net_id": net_id})
17+
if not user:
18+
raise GraphQLError("User not found.")
19+
identity = str(user["_id"])
20+
return LoginUser(
21+
access_token=create_access_token(identity=identity),
22+
refresh_token=create_refresh_token(identity=identity),
23+
)

src/mutations/logout_user.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from datetime import datetime, timezone
2+
3+
from graphene import Mutation, Boolean
4+
5+
from flask_jwt_extended import get_jwt, jwt_required
6+
from src.database import db
7+
8+
9+
class LogoutUser(Mutation):
10+
success = Boolean()
11+
12+
@jwt_required(verify_type=False)
13+
def mutate(self, info):
14+
token = get_jwt()
15+
jti = token["jti"]
16+
exp = token["exp"]
17+
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
18+
db["token_blocklist"].insert_one({"jti": jti, "expires_at": expires_at})
19+
return LogoutUser(success=True)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from graphene import Mutation, String
2+
3+
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required
4+
5+
6+
class RefreshAccessToken(Mutation):
7+
new_access_token = String()
8+
9+
@jwt_required(refresh=True)
10+
def mutate(self, info):
11+
identity = get_jwt_identity()
12+
return RefreshAccessToken(
13+
new_access_token=create_access_token(identity=identity),
14+
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from bson import ObjectId
2+
from graphene import Mutation, String, Boolean
3+
4+
from flask_jwt_extended import get_jwt_identity, jwt_required
5+
from src.database import db
6+
7+
8+
class RemoveFavoriteGame(Mutation):
9+
class Arguments:
10+
game_id = String(required=True, description="ID of the game to remove from favorites.")
11+
12+
success = Boolean()
13+
14+
@jwt_required()
15+
def mutate(self, info, game_id):
16+
user_id = get_jwt_identity()
17+
db["users"].update_one(
18+
{"_id": ObjectId(user_id)},
19+
{"$pull": {"favorite_game_ids": game_id}},
20+
)
21+
return RemoveFavoriteGame(success=True)

0 commit comments

Comments
 (0)