From 0984271a113eccc208fd9440ab4a9684cf638871 Mon Sep 17 00:00:00 2001 From: JustIceO7 Date: Tue, 11 Feb 2025 02:22:27 +0000 Subject: [PATCH 1/7] FEAT: Added Google OAuth login feature to sign up as well as login --- frontend/public/images/icons/google-icon.png | Bin 0 -> 1209 bytes frontend/src/components/Auth/LoginForm.tsx | 2 + frontend/src/components/Auth/OAuth.tsx | 23 ++++ web_server/blueprints/__init__.py | 6 + web_server/blueprints/authentication.py | 11 +- web_server/blueprints/middleware.py | 3 + web_server/blueprints/oauth.py | 124 +++++++++++++------ web_server/database/app.db | Bin 159744 -> 159744 bytes web_server/database/users.sql | 6 +- web_server/requirements.txt | 3 +- web_server/utils/user_utils.py | 6 +- 11 files changed, 128 insertions(+), 56 deletions(-) create mode 100644 frontend/public/images/icons/google-icon.png diff --git a/frontend/public/images/icons/google-icon.png b/frontend/public/images/icons/google-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3085a7ca527de0357872287be578fdf7dc93e65b GIT binary patch literal 1209 zcmV;q1V;ObP)2~~U`roFM3lDCfR}jAY(i|A&Y3i7TQ!+=@lqjTK_MtUv}(QJY|=~e z;zRKQ3Td0!T~ zW!WA0edp}=oq+{`Kp+qZ1OkCTAV@Wq4*9ee-9c4pM^MEt2Z?lm68t`U-O8@72MK;6 zM4aUoKpx}@ClM-SVh=OHqm)P!Ox}tcf=8)}4^x7+@M2`IxQZK@bcjn`ZP-1|@|}@Y zg%~1Eg{snDTwtXVB+~Jrn#%Rru-#jNL|TX?cp~OUvx)gOo_`@v}gv2FoF(5>drj z%ie#MrHc|g7DZkMyD_5T+o_6Y5*=q$MUV2s5X4}3%iY)ULlQp=Ect#D*pVQSK2A`+ z^=Ef^(X#Gci*I4yJwXZH3wC6>r?@&)f7kah#rZfS&Yf_TShDN@yD}Yc^n{N&7DspA z`VEtvy>b!oYmkfl>xZ>rzhjOS!Qz>CXZ4S?29I2_`d>$muQ|p{pzs+kzBE%uOGoI| zik~ia?D?RA%OF=_tKSuok0jh*IQ!4evVowAdm)zz`wM>M!qE~wa>FB#Af##RA>x^M zHQ8*|%|=KEmLFDy1CIHq@&U)f#AmP?5`=inHsTq#l8xdq|I>9VKlQ8sDcl@$_5F@> zz({uicsA`je-osd3;v>VE*drdf>W6)fYLl!miYn%3QlLL0J1)Q?=}4sQ1!_TwrGNr zU%;B&>;aK6_*<-9Fw|eVE$vf4H!q}R3UJsecSqU&v%cPS0qoU$kRaQ2TDq}i;>LJU z^s(yLV{rT|q>E{Pe#7Nkzi{}4?v1Cg*JUBYHCNfGt%nsv(Z_ek+y%OMC}R&p!^cWa zKhU{tn9DE8=deP!b*qSY%)yuq&|@~F`*GOEim-U1Z|#>=9d)x2@ko%Ny|OZ^Z|;e+ z`m^eR$u*Ep#`=q^n|mt0c+3akM0ahl*KkO`P%FBgp&!?nn+d(j8a z6)sy|H=iaRb0+Q-XfiiJCU6?=+mNA6bJ6(G&Y*T}D_D>h|GxG2u>1O*ZH?b5O>`>G z_8)*ug1q%Fa=`?{C&VAvaJ|h0>)7i`sl9Fk>U29LZ+$)ADcI0XF8#dJ{`lXUTh9C$ z?Fq1^dGt=mG)$%~x^(j4yX^jh9Y0*52)n=z$cGz>`PgHzQklvR_Z(N6&d*&u_;dqU zd0-dT9ei+Aq$#E_M|S)o@BjTHel`HRA%7)1u;JQ=3~fGD1>pU|?Q<7^ox$yHnK!!? z77yLm;o1(_0-yWH``GiW5JSm`Bd==|8=7A3b6*eH2>)5|`OhS}n9z7);@T7w?_+7G z^0`YOXRsg-d);NILwky47c#W{$g91}gj?D54gNlEcUADBi9Zzz1OkCTAP@)yf>iQ9 XN = ({ onSubmit }) => { /> + diff --git a/frontend/src/components/Auth/OAuth.tsx b/frontend/src/components/Auth/OAuth.tsx index e69de29..a3c5446 100644 --- a/frontend/src/components/Auth/OAuth.tsx +++ b/frontend/src/components/Auth/OAuth.tsx @@ -0,0 +1,23 @@ +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/web_server/blueprints/__init__.py b/web_server/blueprints/__init__.py index ec462fa..a6361fe 100644 --- a/web_server/blueprints/__init__.py +++ b/web_server/blueprints/__init__.py @@ -9,6 +9,7 @@ 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 @@ -24,10 +25,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 +51,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 +68,7 @@ def create_app(): app.register_blueprint(user_bp) app.register_blueprint(stream_bp) app.register_blueprint(chat_bp) + app.register_blueprint(oauth_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/database/app.db b/web_server/database/app.db index 89703241f94b779345011c7aca83fbfbd282f802..dee097635e4fc6412060706de859cd27fc359469 100644 GIT binary patch delta 262 zcmV+h0r~!b;0b`>36L8RVgLXDCjbBd7XSbN0ssI5eUU6P0b;RWsXq(l58DsX562Jn z54y7vAgK?Ny-zs-2DA514ZsEr0~`Pku9plD0UEa?5CO>u0t_v;?-&6-0|XAz0x`ES z90A@#0Rp!XPyz4{wr~Lf5Ge}!01yRM01uoGG7hv3K@HIjWeS1{5($k75D1zGC&^lh6qS zlN=3;DIi2eMnP3fR3Il*XlZjGb#rBMAY^Z4b0BVSbRcM9c4Z)8AVO(xE+>36L8RRsaA1CjbBd7XSbN0ssI5cabbJ0ameKsXq(#59JTr577_D z54y7vAgK?Ny-zs-0kii{4ZsKu0~`Pkt_&@g3J?Jrw-;Dtw;=N8X?f?J) 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..1fd796f 100644 --- a/web_server/utils/user_utils.py +++ b/web_server/utils/user_utils.py @@ -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 From a8ed15f3a480878215a867d5973de6d412eb6f7f Mon Sep 17 00:00:00 2001 From: JustIceO7 Date: Tue, 11 Feb 2025 02:49:36 +0000 Subject: [PATCH 2/7] UPDATE: Added more routes for subscriptions --- web_server/blueprints/user.py | 14 ++++++++++++++ web_server/utils/user_utils.py | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) 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/utils/user_utils.py b/web_server/utils/user_utils.py index 1fd796f..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]: @@ -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: """ From 3b9fef6a4d0c4cde1b686dc83de9d79b4acfa390 Mon Sep 17 00:00:00 2001 From: EvanLin3141 Date: Tue, 11 Feb 2025 09:20:15 +0000 Subject: [PATCH 3/7] UPDATE: Forgot Password Set in Login Page --- frontend/src/components/Auth/AuthModal.tsx | 10 +-- frontend/src/components/Auth/LoginForm.tsx | 86 ++++++++++++---------- 2 files changed, 50 insertions(+), 46 deletions(-) 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({ @@ -98,45 +100,53 @@ 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}

+ )} + - - - -
+ + + + + +
); }; From 22d6ec482cf2ba292ec26b7e3f08aadcbdabe50e Mon Sep 17 00:00:00 2001 From: EvanLin3141 Date: Tue, 11 Feb 2025 11:08:26 +0000 Subject: [PATCH 4/7] UPDATE: Google Login Responsiveness --- frontend/src/components/Auth/LoginForm.tsx | 23 +++++++++--------- frontend/src/components/Auth/OAuth.tsx | 27 +++++++++++++--------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/Auth/LoginForm.tsx b/frontend/src/components/Auth/LoginForm.tsx index e520b80..8344472 100644 --- a/frontend/src/components/Auth/LoginForm.tsx +++ b/frontend/src/components/Auth/LoginForm.tsx @@ -102,11 +102,11 @@ const LoginForm: React.FC = ({ onSubmit, onForgotPassword }) => { return ( <>
-

Login

+

Login

{errors.general && (

{errors.general}

@@ -134,18 +134,19 @@ const LoginForm: React.FC = ({ onSubmit, onForgotPassword }) => { onChange={handleInputChange} extraClasses={`${errors.password ? "border-red-500" : ""}`} /> + - - - +
+ +
); diff --git a/frontend/src/components/Auth/OAuth.tsx b/frontend/src/components/Auth/OAuth.tsx index a3c5446..7f89aff 100644 --- a/frontend/src/components/Auth/OAuth.tsx +++ b/frontend/src/components/Auth/OAuth.tsx @@ -7,17 +7,22 @@ export default function GoogleLogin() { }; return ( -
- +
+
+ +
); } From 905e879c6053a936793608584da3d74e09640e5a Mon Sep 17 00:00:00 2001 From: Chris-1010 <122332721@umail.ucc.ie> Date: Tue, 11 Feb 2025 13:42:28 +0000 Subject: [PATCH 5/7] UPDATE: Searchbar functions on Frontend --- frontend/src/components/Layout/Navbar.tsx | 13 +---- frontend/src/components/Layout/SearchBar.tsx | 52 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/Layout/SearchBar.tsx 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..f21a46f --- /dev/null +++ b/frontend/src/components/Layout/SearchBar.tsx @@ -0,0 +1,52 @@ +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()) { + fetch(`/api/search/${debouncedQuery}`) + .then((response) => response.json()) + .then((data) => { + console.log("Search results:", data); + // Handle the search results here + }) + .catch((error) => { + console.error("Error performing search:", error); + }); + } + }, [debouncedQuery]); + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + }; + + return ( + + ); +}; + +export default SearchBar; From 1a572cc1720076d9ea3f3417b421cfaf4aec863b Mon Sep 17 00:00:00 2001 From: white <122345776@umail.ucc.ie> Date: Tue, 11 Feb 2025 16:38:46 +0000 Subject: [PATCH 6/7] FEAT: Implemented working search bar using FTS and Ranking --- frontend/src/components/Layout/SearchBar.tsx | 23 +++++++--- web_server/blueprints/__init__.py | 4 +- web_server/blueprints/search_bar.py | 45 +++++++++++-------- web_server/database/app.db | Bin 159744 -> 159744 bytes web_server/utils/utils.py | 7 ++- 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Layout/SearchBar.tsx b/frontend/src/components/Layout/SearchBar.tsx index f21a46f..93f3da1 100644 --- a/frontend/src/components/Layout/SearchBar.tsx +++ b/frontend/src/components/Layout/SearchBar.tsx @@ -18,17 +18,28 @@ const SearchBar: React.FC = () => { // Perform search when debounced query changes useEffect(() => { if (debouncedQuery.trim()) { - fetch(`/api/search/${debouncedQuery}`) - .then((response) => response.json()) - .then((data) => { + 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) => { + } catch (error) { console.error("Error performing search:", error); - }); + } + }; + + fetchSearchResults(); // Call the async function } }, [debouncedQuery]); + const handleSearchChange = (e: React.ChangeEvent) => { setSearchQuery(e.target.value); diff --git a/web_server/blueprints/__init__.py b/web_server/blueprints/__init__.py index a6361fe..be59ce4 100644 --- a/web_server/blueprints/__init__.py +++ b/web_server/blueprints/__init__.py @@ -12,7 +12,8 @@ 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 @@ -69,6 +70,7 @@ def create_app(): 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/search_bar.py b/web_server/blueprints/search_bar.py index 48a5588..2f70cf4 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,32 +21,37 @@ 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}) diff --git a/web_server/database/app.db b/web_server/database/app.db index dee097635e4fc6412060706de859cd27fc359469..7ff7e7329db425e794f8822de5cb80b7451915c4 100644 GIT binary patch delta 956 zcmb_aOH5Ni6rDp~d2OlhJpnCJC{z#%tjO!Xt$--PE|ti-DUR=VY;xm(q_OiYR9k2o438nS8_a$Z%h8Hw>nJPkn0K zWz3h_1&c1n{K_1cGjUl`*w^u~?P@|nCREg80BTMKGK?u~%Y?_gkrW%+)Sw^7LG3mi z5SlG(izmv&W%S;Ly-k-X(kEN%-oiGKGmh4(L2*-(>9pi7=IY%iq-RyrJjNcRG;t8KNog(duB_lp**6~isz&h2|c zec|3vCn<4s=ypc^=&@?HGj9}#%t*Z(b#gJTS`rpPt4swv0d0b0m+9G}ok z&KQ@nh#F5|8TG|bf+o5UgBvTfQZ~Jfp){pYq|I)WqwqIuYaCuQ(0CjU;#}F9O`7!e zqQ&W?sTeY8-i>r_M^4a+I$GnwLDbSi4=x}`?G;!+fVO(^3^i2hd9qon9do(OmIVuQnbAENB}8FuH3~8{ScyG5#8tn5k&n6g6Q_6Ki!~+f*|TgL^t|2qs#a4^71~<^SG2T9DFH` zmfK?-$9Y&dS(rG^Jdp9uLhRzkcSqbR zx@@hy)2xnP9JMLjx6EkFNGq5Cfk<6^VyM#d^Z3 z34Y5@OH2NCOA8Zm+$0w2Ke_yJrQ)#g3AbKF>ql^gPuA#~VLE^S;Gy6_e8zODPN9ZX z4Tn%hT`6YMG&-GvpD_%YNN-b^VR8$!&W}Dm0ex!jO>mDlJRW8yO%a^c)ijo3o`3M; zUiRf(bTfbs<5V5KfILC$rWc1%iAq8m3)nsurs0K)cBav2?$;;Mg&^FRoMoN#K8X3| hEqWRC)nhs;vS2A)tH%tMUezOd^#QZb;79TO{x8Uu^hf{z 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 From 46361c796001d61ce73de5f284b940ad8cd9d8df Mon Sep 17 00:00:00 2001 From: white <122345776@umail.ucc.ie> Date: Tue, 11 Feb 2025 16:57:09 +0000 Subject: [PATCH 7/7] UPDATE: Tidied up search bar and made changes to the specific search routes --- web_server/blueprints/search_bar.py | 63 ++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/web_server/blueprints/search_bar.py b/web_server/blueprints/search_bar.py index 2f70cf4..8cfb422 100644 --- a/web_server/blueprints/search_bar.py +++ b/web_server/blueprints/search_bar.py @@ -55,34 +55,52 @@ def search_results(): 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() @@ -90,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()