diff --git a/frontend/public/images/icons/google-icon.png b/frontend/public/images/icons/google-icon.png new file mode 100644 index 0000000..3085a7c Binary files /dev/null and b/frontend/public/images/icons/google-icon.png differ diff --git a/frontend/src/components/Auth/AuthModal.tsx b/frontend/src/components/Auth/AuthModal.tsx index 0453d17..74023f9 100644 --- a/frontend/src/components/Auth/AuthModal.tsx +++ b/frontend/src/components/Auth/AuthModal.tsx @@ -25,7 +25,7 @@ const AuthModal: React.FC = ({ onClose }) => { const authSwitch = () => { const formMap: { [key: string]: JSX.Element} = { - Login: , + Login: setSelectedTab("Forgot")}/>, Register: , Forgot: }; @@ -78,13 +78,7 @@ const AuthModal: React.FC = ({ onClose }) => { Register - setSelectedTab("Forgot")}> - - Forgot Password - +
void; + onForgotPassword: () => void; } -const LoginForm: React.FC = ({ onSubmit }) => { +const LoginForm: React.FC = ({ onSubmit, onForgotPassword }) => { const { setIsLoggedIn } = useAuth(); const [formData, setFormData] = useState({ @@ -97,44 +100,54 @@ const LoginForm: React.FC = ({ onSubmit }) => { }; return ( - <> -
-

Login

-
- {errors.general && ( -

{errors.general}

- )} + <> +
+

Login

+ + {errors.general && ( +

{errors.general}

+ )} - {errors.username && ( -

{errors.username}

- )} - + {errors.username && ( +

{errors.username}

+ )} + - {errors.password && ( -

{errors.password}

- )} - + {errors.password && ( +

{errors.password}

+ )} + + - - -
+ + +
+ +
+
); }; diff --git a/frontend/src/components/Auth/OAuth.tsx b/frontend/src/components/Auth/OAuth.tsx index e69de29..7f89aff 100644 --- a/frontend/src/components/Auth/OAuth.tsx +++ b/frontend/src/components/Auth/OAuth.tsx @@ -0,0 +1,28 @@ +import { useEffect } from "react"; + +export default function GoogleLogin() { + const handleLoginClick = (e: React.MouseEvent) => { + e.preventDefault(); + window.location.href = "/api/login/google"; + }; + + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/Layout/Navbar.tsx b/frontend/src/components/Layout/Navbar.tsx index 76c5386..509e9d0 100644 --- a/frontend/src/components/Layout/Navbar.tsx +++ b/frontend/src/components/Layout/Navbar.tsx @@ -4,12 +4,11 @@ import Button from "./Button"; import Sidebar from "./Sidebar"; import { Sidebar as SidebarIcon } from "lucide-react"; import { - Search as SearchIcon, LogIn as LogInIcon, LogOut as LogOutIcon, Settings as SettingsIcon, } from "lucide-react"; -import Input from "./Input"; +import SearchBar from "./SearchBar"; import AuthModal from "../Auth/AuthModal"; import { useAuthModal } from "../../hooks/useAuthModal"; import { useAuth } from "../../context/AuthContext"; @@ -109,15 +108,7 @@ const Navbar: React.FC = ({ variant = "default" }) => {
- + {showAuthModal && setShowAuthModal(false)} />} diff --git a/frontend/src/components/Layout/SearchBar.tsx b/frontend/src/components/Layout/SearchBar.tsx new file mode 100644 index 0000000..93f3da1 --- /dev/null +++ b/frontend/src/components/Layout/SearchBar.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from "react"; +import Input from "./Input"; +import { Search as SearchIcon } from "lucide-react"; + +const SearchBar: React.FC = () => { + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(searchQuery); + + // Debounce the search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery); + }, 500); // Wait 500ms after user stops typing + + return () => clearTimeout(timer); + }, [searchQuery]); + + // Perform search when debounced query changes + useEffect(() => { + if (debouncedQuery.trim()) { + const fetchSearchResults = async () => { + try { + const response = await fetch("/api/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ query: debouncedQuery }), // <-- Fixed payload + }); + + const data = await response.json(); + console.log("Search results:", data); + // Handle the search results here + } catch (error) { + console.error("Error performing search:", error); + } + }; + + fetchSearchResults(); // Call the async function + } + }, [debouncedQuery]); + + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + }; + + return ( + + ); +}; + +export default SearchBar; diff --git a/web_server/blueprints/__init__.py b/web_server/blueprints/__init__.py index ec462fa..be59ce4 100644 --- a/web_server/blueprints/__init__.py +++ b/web_server/blueprints/__init__.py @@ -9,9 +9,11 @@ from blueprints.stripe import stripe_bp from blueprints.user import user_bp from blueprints.streams import stream_bp from blueprints.chat import chat_bp +from blueprints.oauth import oauth_bp, init_oauth from blueprints.socket import socketio from celery import Celery -from celery_tasks import celery_init_app +from celery_tasks import celery_init_app# +from blueprints.search_bar import search_bp from os import getenv @@ -24,10 +26,13 @@ def create_app(): And setup web sockets to be used throughout the project. """ app = Flask(__name__) + app.config["SERVER_NAME"] = "127.0.0.1:8080" app.config["SECRET_KEY"] = getenv("FLASK_SECRET_KEY") app.config["SESSION_PERMANENT"] = False app.config["SESSION_TYPE"] = "filesystem" app.config["PROPAGATE_EXCEPTIONS"] = True + app.config['GOOGLE_CLIENT_ID'] = getenv("GOOGLE_CLIENT_ID") + app.config['GOOGLE_CLIENT_SECRET'] = getenv("GOOGLE_CLIENT_SECRET") app.config.from_mapping( CELERY=dict( @@ -47,6 +52,7 @@ def create_app(): Session(app) app.before_request(logged_in_user) + init_oauth(app) # adds in error handlers register_error_handlers(app) @@ -63,6 +69,8 @@ def create_app(): app.register_blueprint(user_bp) app.register_blueprint(stream_bp) app.register_blueprint(chat_bp) + app.register_blueprint(oauth_bp) + app.register_blueprint(search_bp) socketio.init_app(app) diff --git a/web_server/blueprints/authentication.py b/web_server/blueprints/authentication.py index d99c25d..21bd2af 100644 --- a/web_server/blueprints/authentication.py +++ b/web_server/blueprints/authentication.py @@ -81,18 +81,13 @@ def signup(): # Create new user once input is validated db.execute( """INSERT INTO users - (username, password, email, num_followers, stream_key, is_partnered, bio, current_stream_title, current_selected_category_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (username, password, email, stream_key) + VALUES (?, ?, ?, ?)""", ( username, generate_password_hash(password), email, - 0, - token_hex(32), - 0, - "This user does not have a Bio.", - "My Stream", - None + token_hex(32) ) ) diff --git a/web_server/blueprints/middleware.py b/web_server/blueprints/middleware.py index 2d43571..b8b581d 100644 --- a/web_server/blueprints/middleware.py +++ b/web_server/blueprints/middleware.py @@ -32,6 +32,9 @@ def admin_required(view): return wrapped_view def register_error_handlers(app): + """ + Default reponses to status codes + """ error_responses = { 400: "Bad Request", 403: "Forbidden", diff --git a/web_server/blueprints/oauth.py b/web_server/blueprints/oauth.py index f45e49b..11821dd 100644 --- a/web_server/blueprints/oauth.py +++ b/web_server/blueprints/oauth.py @@ -1,60 +1,102 @@ from authlib.integrations.flask_client import OAuth, OAuthError -from flask import Blueprint, url_for, jsonify, session +from flask import Blueprint, jsonify, session, redirect from blueprints.user import get_session_info_email +from database.database import Database +from secrets import token_hex, token_urlsafe +from random import randint oauth_bp = Blueprint("oauth", __name__) +google = None + def init_oauth(app): oauth = OAuth(app) - + global google google = oauth.register( 'google', client_id=app.config['GOOGLE_CLIENT_ID'], client_secret=app.config['GOOGLE_CLIENT_SECRET'], authorize_url='https://accounts.google.com/o/oauth2/auth', - authorize_params=None, - access_token_url='https://accounts.google.com/o/oauth2/token', - access_token_params=None, - refresh_token_url=None, - redirect_uri=url_for('google.google_auth', _external=True), - scope='openid profile email', + access_token_url='https://oauth2.googleapis.com/token', + client_kwargs={'scope': 'openid profile email'}, + api_base_url='https://www.googleapis.com/oauth2/v1/', + userinfo_endpoint='https://openidconnect.googleapis.com/v1/userinfo', + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + redirect_uri="http://127.0.0.1:8080/api/google_auth" ) +@oauth_bp.route('/login/google') +def login_google(): + """ + Redirects to Google's OAuth authorization page + """ + # Creates nonce to be sent + session["nonce"] = token_urlsafe(16) + return google.authorize_redirect( + 'http://127.0.0.1:8080/api/google_auth', + nonce=session['nonce'] + ) - @oauth_bp.route('/login/google') - def login_google(): - """ - Redirects to Google's OAuth authorization page - """ - return google.authorize_redirect(url_for('google.google_auth', _external=True)) +@oauth_bp.route('/google_auth') +def google_auth(): + """ + Receives token from Google OAuth and authenticates it to validate login + """ + try: + token = google.authorize_access_token() - @oauth_bp.route('/google_auth') - def google_auth(): - try: - token = google.authorize_access_token() - user = google.parse_id_token(token) - - # check if email exists else create a database entry - user_email = user.get("email") + # Verifies token as well as nonce + nonce = session.pop('nonce', None) + if not nonce: + return jsonify({'error': 'Missing nonce in session'}), 400 + + user = google.parse_id_token(token, nonce=nonce) + print(user, flush=True) + + # Check if email exists to login else create a database entry + user_email = user.get("email") + + user_data = get_session_info_email(user_email) + + if not user_data: + with Database() as db: + # Generates a new username for the user + for _ in range(1000000): + username = user.get("given_name") + str(randint(1, 1000000)) + taken = db.fetchone(""" + SELECT * FROM users + WHERE username = ? + """, (username,)) + + if not taken: + break + + db.execute( + """INSERT INTO users + (username, email, stream_key) + VALUES (?, ?, ?)""", + ( + username, + user_email, + token_hex(32), + ) + ) user_data = get_session_info_email(user_email) - session.clear() - session["username"] = user_data["username"] - session["user_id"] = user_data["user_id"] + session.clear() + session["username"] = user_data["username"] + session["user_id"] = user_data["user_id"] - return jsonify({ - 'message': 'User authenticated successfully', - }) - - except OAuthError as e: - # Handle OAuth errors like failed authentication or invalid token - return jsonify({ - 'message': 'Authentication failed', - 'error': str(e) - }), 400 + # TODO: redirect back to original page user started on, or other pages based on success failure of login + return redirect("http://127.0.0.1:8080/") - except Exception as e: - # Handle other unexpected errors - return jsonify({ - 'message': 'An unexpected error occurred', - 'error': str(e) - }), 500 \ No newline at end of file + except OAuthError as e: + return jsonify({ + 'message': 'Authentication failed', + 'error': str(e) + }), 400 + + except Exception as e: + return jsonify({ + 'message': 'An unexpected error occurred', + 'error': str(e) + }), 500 diff --git a/web_server/blueprints/search_bar.py b/web_server/blueprints/search_bar.py index 48a5588..8cfb422 100644 --- a/web_server/blueprints/search_bar.py +++ b/web_server/blueprints/search_bar.py @@ -1,15 +1,19 @@ -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, request from database.database import Database +from utils.utils import sanitize search_bp = Blueprint("search", __name__) -@search_bp.route("/search/", methods=["GET", "POST"]) -def search_results(query: str): +@search_bp.route("/search", methods=["POST"]) +def search_results(): """ Return the most similar search results This is the main route that displays a subsection of each search topic """ + data = request.get_json() + query = sanitize(data["query"]) + # Create the connection to the database db = Database() db.create_connection() @@ -17,63 +21,86 @@ def search_results(query: str): # Get the most accurate search results # 3 categories categories = db.fetchall(""" - SELECT bm25(category_fts), rank, f.category_id, f.category_name - FROM categories AS c - INNER JOIN category_fts AS f ON c.category_id = f.category_id - WHERE category_fts MATCH ? - LIMIT 3; + SELECT bm25(category_fts) AS score, c.category_id, c.category_name + FROM categories AS c + INNER JOIN category_fts AS f ON c.category_id = f.category_id + WHERE f.category_name LIKE '%' || ? || '%' + ORDER BY score ASC + LIMIT 3; """, (query,)) # 3 users users = db.fetchall(""" - SELECT bm25(user_fts), rank, f.user_id, f.username, f.is_live - FROM users u - INNER JOIN user_fts f ON u.user_id = f.user_id - WHERE user_fts MATCH ? - LIMIT 3; + SELECT bm25(user_fts) AS score, u.user_id, u.username, u.is_live + FROM users AS u + INNER JOIN user_fts AS f ON u.user_id = f.user_id + WHERE f.username LIKE '%' || ? || '%' + ORDER BY score ASC + LIMIT 3; """, (query,)) # 3 streams streams = db.fetchall(""" - SELECT bm25(stream_fts), rank, f.user_id, f.title, f.num_viewers, f.category_id - FROM streams s - INNER JOIN stream_fts f ON s.user_id = f.user_id - WHERE stream_fts MATCH ? - LIMIT 3; + SELECT bm25(stream_fts) AS score, s.user_id, s.title, s.num_viewers, s.category_id + FROM streams AS s + INNER JOIN stream_fts AS f ON s.user_id = f.user_id + WHERE f.title LIKE '%' || ? || '%' + ORDER BY score ASC + LIMIT 3; """, (query,)) db.close_connection() + + print(query, streams, users, categories, flush=True) return jsonify({"categories": categories, "users": users, "streams": streams}) -@search_bp.route("/search/categories/", methods=["GET", "POST"]) -def search_categories(query: str): +@search_bp.route("/search/categories", methods=["GET", "POST"]) +def search_categories(): + """ + Display all the results for categories from the specified user query + """ + # Receive the query data from the user + data = request.get_json() + query = sanitize(data["query"]) + # Create the connection to the database db = Database() db.create_connection() + # Fetch the ranked data and send to JSON to be displayed categories = db.fetchall(""" - SELECT bm25(category_fts), rank, f.category_id, f.category_name - FROM categories AS c - INNER JOIN category_fts AS f ON c.category_id = f.category_id - WHERE category_fts MATCH ?; + SELECT bm25(category_fts) AS score, c.category_id, c.category_name + FROM categories AS c + INNER JOIN category_fts AS f ON c.category_id = f.category_id + WHERE f.category_name LIKE '%' || ? || '%' + ORDER BY score ASC; """, (query,)) db.close_connection() return jsonify({"categories": categories}) -@search_bp.route("/search/users/", methods=["GET", "POST"]) -def search_users(query: str): +@search_bp.route("/search/users", methods=["GET", "POST"]) +def search_users(): + """ + Display all the results for users from the specified user query + """ + # Receive the query data from the user + data = request.get_json() + query = sanitize(data["query"]) + # Create the connection to the database db = Database() db.create_connection() + # Fetch the ranked data and send to JSON to be displayed users = db.fetchall(""" - SELECT bm25(user_fts), rank, f.user_id, f.username, f.is_live - FROM users u - INNER JOIN user_fts f ON u.user_id = f.user_id - WHERE user_fts MATCH ?; + SELECT bm25(user_fts) AS score, u.user_id, u.username, u.is_live + FROM users AS u + INNER JOIN user_fts AS f ON u.user_id = f.user_id + WHERE f.username LIKE '%' || ? || '%' + ORDER BY score ASC; """, (query,)) db.close_connection() @@ -81,17 +108,26 @@ def search_users(query: str): return jsonify({"users": users}) -@search_bp.route("/search/streams/", methods=["GET", "POST"]) -def search_streams(query: str): +@search_bp.route("/search/streams", methods=["GET", "POST"]) +def search_streams(): + """ + Display all the results for streams from the specified user query + """ + # Receive the query data from the user + data = request.get_json() + query = sanitize(data["query"]) + # Create the connection to the database db = Database() db.create_connection() + # Fetch the ranked data and send to JSON to be displayed streams = db.fetchall(""" - SELECT bm25(stream_fts), rank, f.user_id, f.title, f.num_viewers, f.category_id - FROM streams s - INNER JOIN stream_fts f ON s.user_id = f.user_id - WHERE stream_fts MATCH ?; + SELECT bm25(stream_fts) AS score, s.user_id, s.title, s.num_viewers, s.category_id + FROM streams AS s + INNER JOIN stream_fts AS f ON s.user_id = f.user_id + WHERE f.title LIKE '%' || ? || '%' + ORDER BY score ASC; """, (query,)) db.close_connection() diff --git a/web_server/blueprints/user.py b/web_server/blueprints/user.py index a6d4918..4622b1f 100644 --- a/web_server/blueprints/user.py +++ b/web_server/blueprints/user.py @@ -22,6 +22,17 @@ def user_data(username: str): return jsonify(data) ## Subscription Routes +@login_required +@user_bp.route('/user/subscribe/') +def user_subscribe(streamer_id): + """ + Given a streamer subscribes as user + """ + #TODO: Keep this route secure so only webhooks from Stripe payment can trigger it + user_id = session.get("user_id") + subscribe(user_id, streamer_id) + return jsonify({"status": True}) + @login_required @user_bp.route('/user/subscription/') def user_subscribed(subscribed_id: int): @@ -42,6 +53,9 @@ def user_subscription_expiration(subscribed_id: int): user_id = session.get("user_id") remaining_time = subscription_expiration(user_id, subscribed_id) + # Remove any expired subscriptions from the table + if remaining_time == 0: + delete_subscription(user_id, subscribed_id) return jsonify({"remaining_time": remaining_time}) diff --git a/web_server/database/users.sql b/web_server/database/users.sql index b72733a..5023caf 100644 --- a/web_server/database/users.sql +++ b/web_server/database/users.sql @@ -3,13 +3,13 @@ CREATE TABLE users ( user_id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(50) NOT NULL, - password VARCHAR(256) NOT NULL, + password VARCHAR(256), email VARCHAR(128) NOT NULL, - num_followers INTEGER NOT NULL, + num_followers INTEGER NOT NULL DEFAULT 0, stream_key VARCHAR(60) NOT NULL, is_partnered BOOLEAN NOT NULL DEFAULT 0, is_live BOOLEAN NOT NULL DEFAULT 0, - bio VARCHAR(1024), + bio VARCHAR(1024) DEFAULT 'This user does not have a Bio.', current_stream_title VARCHAR(100), current_selected_category_id INTEGER diff --git a/web_server/requirements.txt b/web_server/requirements.txt index 349268f..1cca4ad 100644 --- a/web_server/requirements.txt +++ b/web_server/requirements.txt @@ -27,4 +27,5 @@ gevent-websocket flask-oauthlib==0.9.6 celery==5.2.3 redis==5.2.1 -python-dateutil \ No newline at end of file +python-dateutil +Authlib==1.4.1 \ No newline at end of file diff --git a/web_server/utils/user_utils.py b/web_server/utils/user_utils.py index 99afc95..91de868 100644 --- a/web_server/utils/user_utils.py +++ b/web_server/utils/user_utils.py @@ -1,6 +1,6 @@ from database.database import Database from typing import Optional, List -from datetime import datetime +from datetime import datetime, timedelta from dateutil import parser def get_user_id(username: str) -> Optional[int]: @@ -22,7 +22,7 @@ def get_username(user_id: str) -> Optional[str]: with Database() as db: data = db.fetchone(""" SELECT username - FROM user + FROM users WHERE user_id = ? """, (user_id,)) return data['username'] if data else None @@ -31,10 +31,10 @@ def get_session_info_email(email: str) -> dict: """ Returns username and user_id given email """ - with Database as db: + with Database() as db: session_info = db.fetchone(""" SELECT user_id, username - FROM user + FROM users WHERE email = ? """, (email,)) return session_info @@ -109,6 +109,39 @@ def unfollow(user_id: int, followed_id: int): """, (user_id, followed_id)) return {"success": True} +def subscribe(user_id: int, streamer_id: int): + """ + Subscribes user_id to streamer_id + """ + # If user is already subscribed then extend the expiration date else create a new entry + with Database() as db: + existing = db.fetchone(""" + SELECT expires + FROM subscribes + WHERE user_id = ? AND subscribed_id = ? + """, (user_id, streamer_id)) + if existing: + db.execute(""" + UPDATE subscribes SET expires = expires + ? + WHERE user_id = ? AND subscribed_id = ? + """, (timedelta(days=30), user_id, streamer_id)) + else: + db.execute(""" + INSERT INTO subscribes + (user_id, subscribed_id, since, expires) + VALUES (?,?,?,?) + """, (user_id, streamer_id, datetime.now(), datetime.now() + timedelta(days=30))) + +def delete_subscription(user_id: int, subscribed_id: int): + """ + Deletes a subscription entry given user_id and streamer_id + """ + with Database() as db: + db.execute(""" + DELETE FROM subscribes + WHERE user_id = ? AND subscribed_id = ? + """, (user_id, subscribed_id)) + def subscription_expiration(user_id: int, subscribed_id: int) -> int: """ diff --git a/web_server/utils/utils.py b/web_server/utils/utils.py index b767ec7..3c2f833 100644 --- a/web_server/utils/utils.py +++ b/web_server/utils/utils.py @@ -37,7 +37,7 @@ def get_most_popular_category() -> Optional[List[dict]]: return category -def sanitize(user_input: str, input_type="username") -> str: +def sanitize(user_input: str, input_type="default") -> str: """ Sanitizes user input based on the specified input type. @@ -63,6 +63,11 @@ def sanitize(user_input: str, input_type="username") -> str: "min_length": 8, "max_length": 256, }, + "default": { + "pattern": r"^[\S]+$", # Non-whitespace characters only + "min_length": 1, + "max_length": 50, + }, } # Get the validation rules for the specified type