diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..d0a61258e76ee16110e5417c521bd904a73f9ed5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +src/apps/static/2023050195.pdf filter=lfs diff=lfs merge=lfs -text +src/apps/static/legal[[:space:]]writing.pdf filter=lfs diff=lfs merge=lfs -text +src/apps/static/LegalTheory_CaseLawNotes.pdf filter=lfs diff=lfs merge=lfs -text +src/apps/static/MootCourt_Advocacy.pdf filter=lfs diff=lfs merge=lfs -text +src/apps/static/video.mp4 filter=lfs diff=lfs merge=lfs -text +src/apps/static/wipo_guide_ipc_2019.pdf filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4feed4961cf6fff388fbac73186af6e75b18e4b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.10-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV KMP_DUPLICATE_LIB_OK=TRUE +ENV FAISS_NO_OPENMP=1 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project files +COPY . . + +# Move to the apps directory +WORKDIR /app/src/apps + +# Expose the port (Hugging Face Spaces uses 7860) +EXPOSE 7860 + +# Run the application +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..32e05d0ab3cffbf2fcfeffc4ce0f597b5d40642d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +asyncpg==0.29.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 +jinja2==3.1.2 +aiofiles==23.2.1 +requests==2.31.0 +openai==1.3.0 +langchain==0.1.0 +langchain-community==0.0.10 +chromadb==0.4.18 +fastapi-mail>=1.5.0 +httpx==0.25.1 +faiss-cpu==1.7.4 +transformers==4.35.2 +torch==2.1.1 +numpy>=1.24.0 +einops==0.7.0 +psycopg2-binary==2.9.9 diff --git a/src/apps/.env b/src/apps/.env new file mode 100644 index 0000000000000000000000000000000000000000..0a27c297aaa8e245273169929288e64793cf7e43 --- /dev/null +++ b/src/apps/.env @@ -0,0 +1,26 @@ +# Database Configuration +DB_USER=postgres +DB_PASSWORD=Vishwa12 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=lawbot_db + +# Security +SECRET_KEY=f1fda542576b459ee24bf2d459711a439576da2d804dcbb35de9e18261 +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# Email Configuration (Gmail) +MAIL_USERNAME=vishwaroman04@gmail.com +MAIL_PASSWORD=vowg uhgs uwwh qspj +MAIL_FROM=vishwaroman04@gmail.com +MAIL_PORT=587 +MAIL_SERVER=smtp.gmail.com + +# OpenRouter API Key (for chatbot - get from https://openrouter.ai/keys) +# Use format: sk-or-v1-xxxxxxxx... (prefix only ONCE) +OPENROUTER_API_KEY=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 + + + + diff --git a/src/apps/__pycache__/app.cpython-312.pyc b/src/apps/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e55184d5112ad154203b6d249d29d46cfcbd3f47 Binary files /dev/null and b/src/apps/__pycache__/app.cpython-312.pyc differ diff --git a/src/apps/__pycache__/auth.cpython-312.pyc b/src/apps/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33afc97dee1f4ec7c1f5e58692ba3e3fe7e4c5d4 Binary files /dev/null and b/src/apps/__pycache__/auth.cpython-312.pyc differ diff --git a/src/apps/__pycache__/database.cpython-312.pyc b/src/apps/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b35b9a2a31fc64188f1479d3ed433501b060c10 Binary files /dev/null and b/src/apps/__pycache__/database.cpython-312.pyc differ diff --git a/src/apps/__pycache__/email_utils.cpython-312.pyc b/src/apps/__pycache__/email_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ec9156dc9fccccdfefb64e68ac563ab5a9cad36 Binary files /dev/null and b/src/apps/__pycache__/email_utils.cpython-312.pyc differ diff --git a/src/apps/__pycache__/models.cpython-312.pyc b/src/apps/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf9fc2a615c9ed5ffa22413844816a8516199497 Binary files /dev/null and b/src/apps/__pycache__/models.cpython-312.pyc differ diff --git a/src/apps/app.py b/src/apps/app.py new file mode 100644 index 0000000000000000000000000000000000000000..701d81d7b0350468c1635d7829ecb947e5054b35 --- /dev/null +++ b/src/apps/app.py @@ -0,0 +1,520 @@ +import bcrypt +# Robust monkeypatch for bcrypt-passlib compatibility +if not hasattr(bcrypt, "__about__"): + bcrypt.__about__ = type('About', (object,), {'__version__': bcrypt.__version__}) + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, Response, Depends, HTTPException, status +from typing import Optional +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +import uvicorn +import asyncio +import os +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from pydantic import BaseModel, EmailStr + +from utils.main import RAG +from database import engine, Base, get_db +from models import User +from auth import get_password_hash, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY +from email_utils import send_reset_email +from datetime import timedelta +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +import uuid + +# Initialize Database +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +app = FastAPI(on_startup=[init_db]) + +chat_history = [] + +# Get the base directory +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +STATIC_DIR = os.path.join(BASE_DIR, "static") +TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") + +# Mount static directories +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +app.mount("/images", StaticFiles(directory=os.path.join(STATIC_DIR, "images")), name="images") + +# Initialize Jinja2 templates +templates = Jinja2Templates(directory=TEMPLATES_DIR) + + +# Pydantic Models for Auth +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + role: str + +class UserLogin(BaseModel): + username: str + password: str + role: Optional[str] = None + +class ForgotPassword(BaseModel): + email: EmailStr + +class ResetPassword(BaseModel): + token: str + new_password: str + +class Interaction(BaseModel): + caseId: str + query: str + response: str + role: str # Role context for this interaction + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/login") + +async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Try to get token from Header first (for API calls), then Cookie (for Page navigation) + token = None + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + else: + token = request.cookies.get("access_token") + + if not token: + raise credentials_exception + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + result = await db.execute(select(User).where(User.username == username)) + user = result.scalars().first() + if user is None: + raise credentials_exception + return user + +def role_required(required_role: str): + async def role_checker(user: User = Depends(get_current_user)): + if user.role != required_role and user.role != "Admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: Requires {required_role} role (or Admin)" + ) + return user + return role_checker + +# Auth Routes +@app.post("/api/register") +async def register(user: UserCreate, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where((User.username == user.username) | (User.email == user.email))) + if result.scalars().first(): + raise HTTPException(status_code=400, detail="Username or Email already registered") + + hashed_password = get_password_hash(user.password) + new_user = User(username=user.username, email=user.email, hashed_password=hashed_password, role=user.role) + db.add(new_user) + await db.commit() + return {"message": "User created successfully"} + +@app.post("/api/login") +async def login(response: Response, user: UserLogin, db: AsyncSession = Depends(get_db)): + print(f"Login attempt for username: {user.username}") + result = await db.execute(select(User).where(User.username == user.username)) + db_user = result.scalars().first() + + if not db_user: + print(f"User not found: {user.username}") + raise HTTPException(status_code=400, detail="Incorrect username or password") + + print(f"User found: {db_user.username}, checking password...") + password_valid = verify_password(user.password, db_user.hashed_password) + print(f"Password valid: {password_valid}") + + if not password_valid: + raise HTTPException(status_code=400, detail="Incorrect username or password") + + # Verify role if provided + if user.role and db_user.role != user.role and db_user.role != "Admin": + print(f"Role mismatch: user has {db_user.role}, but tried to login as {user.role}") + raise HTTPException(status_code=403, detail=f"Access denied: This account is registered as {db_user.role}, not {user.role}") + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token(data={"sub": db_user.username, "role": db_user.role}, expires_delta=access_token_expires) + + # Set secure cookie + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + samesite="lax", + secure=False # Set to True in production with HTTPS + ) + + print(f"Login successful for {db_user.username}, role: {db_user.role}") + return { + "access_token": access_token, + "token_type": "bearer", + "role": db_user.role, + "question_count": db_user.question_count, + "is_admin": db_user.role == "Admin" + } + +@app.post("/api/logout") +async def logout(response: Response): + response.delete_cookie("access_token") + return {"message": "Logged out successfully"} + +@app.post("/api/forgot-password") +async def forgot_password(request: ForgotPassword, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == request.email)) + user = result.scalars().first() + + if user: + token = str(uuid.uuid4()) + user.reset_token = token + await db.commit() + try: + await send_reset_email(user.email, token) + except Exception as e: + print(f"Error sending email: {e}") + raise HTTPException(status_code=500, detail=f"Failed to send email: {str(e)}") + + return {"message": "If an account exists, a reset email has been sent"} + +@app.post("/api/reset-password") +async def reset_password(request: ResetPassword, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.reset_token == request.token)) + user = result.scalars().first() + + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + user.hashed_password = get_password_hash(request.new_password) + user.reset_token = None + await db.commit() + return {"message": "Password reset successful"} + +@app.post("/api/save-interaction") +async def save_interaction(interaction: Interaction, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): + from models import ChatInteraction + + # Create new chat interaction record linked to user with role + new_interaction = ChatInteraction( + case_id=interaction.caseId, + query=interaction.query, + response=interaction.response, + role=interaction.role, + user_id=current_user.id + ) + + db.add(new_interaction) + await db.commit() + await db.refresh(new_interaction) + + print(f"Saved interaction for user {current_user.username}: ID={new_interaction.id}, CaseID={interaction.caseId}") + return {"status": "success", "message": "Interaction saved", "id": new_interaction.id} + +@app.get("/api/interactions") +async def get_interactions(role: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): + from models import ChatInteraction + from sqlalchemy import func + + # Build base where clause + where_clause = ChatInteraction.user_id == current_user.id + if role: + where_clause = (ChatInteraction.user_id == current_user.id) & (ChatInteraction.role == role) + + # Subquery to find the latest interaction per case_id (filtered by role if specified) + subquery = ( + select( + ChatInteraction.case_id, + func.max(ChatInteraction.created_at).label("max_created") + ) + .where(where_clause) + .group_by(ChatInteraction.case_id) + .subquery() + ) + + # Join to get query detail for the latest message in each case + result = await db.execute( + select(ChatInteraction) + .join(subquery, (ChatInteraction.case_id == subquery.c.case_id) & (ChatInteraction.created_at == subquery.c.max_created)) + .order_by(ChatInteraction.created_at.desc()) + .limit(20) + ) + interactions = result.scalars().all() + return [{"id": i.id, "case_id": i.case_id, "query": i.query, "created_at": i.created_at} for i in interactions] + +@app.get("/api/interactions/{case_id}") +async def get_conversation_thread(case_id: str, role: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): + from models import ChatInteraction + + # Build where clause with optional role filtering + where_clause = (ChatInteraction.user_id == current_user.id) & (ChatInteraction.case_id == case_id) + if role: + where_clause = where_clause & (ChatInteraction.role == role) + + result = await db.execute( + select(ChatInteraction) + .where(where_clause) + .order_by(ChatInteraction.created_at.asc()) + ) + interactions = result.scalars().all() + if not interactions: + raise HTTPException(status_code=404, detail="Conversation not found") + + return [{"query": i.query, "response": i.response, "created_at": i.created_at} for i in interactions] + +@app.delete("/api/interactions/{case_id}") +async def delete_conversation(case_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): + from models import ChatInteraction + from sqlalchemy import delete as sqlalchemy_delete + + await db.execute( + sqlalchemy_delete(ChatInteraction) + .where((ChatInteraction.user_id == current_user.id) & (ChatInteraction.case_id == case_id)) + ) + await db.commit() + return {"status": "success", "message": "Conversation deleted"} + +@app.get("/api/user-status") +async def get_user_status(current_user: User = Depends(get_current_user)): + return { + "username": current_user.username, + "role": current_user.role, + "question_count": current_user.question_count, + "limit": 2 if current_user.role != "Admin" else None, + "is_admin": current_user.role == "Admin" + } + +# Frontend Routes for Auth +@app.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + +@app.get("/register", response_class=HTMLResponse) +async def register_page(request: Request): + return templates.TemplateResponse("register.html", {"request": request}) + +@app.get("/forgot-password", response_class=HTMLResponse) +async def forgot_password_page(request: Request): + return templates.TemplateResponse("forgot_password.html", {"request": request}) + +@app.get("/reset-password", response_class=HTMLResponse) +async def reset_password_page(request: Request): + return templates.TemplateResponse("reset_password.html", {"request": request}) + +# Home and Role Selection +@app.get("/", response_class=HTMLResponse) +async def role_selection(request: Request): + return templates.TemplateResponse("roleselection.html", {"request": request}) + +@app.get("/role", response_class=HTMLResponse) +async def roleselection_page(request: Request): + return templates.TemplateResponse("roleselection.html", {"request": request}) + +# Chatbot Pages +@app.get("/judgechatbot.html", response_class=HTMLResponse) +async def judge_chatbot(request: Request, user: User = Depends(role_required("Judge"))): + return templates.TemplateResponse("Judgechatbot.html", {"request": request}) + +@app.get("/judgedashboard.html", response_class=HTMLResponse) +async def judge_dashboard(request: Request, user: User = Depends(role_required("Judge"))): + return templates.TemplateResponse("judgedashboard.html", {"request": request}) + +@app.get("/viewall.html", response_class=HTMLResponse) +async def view_all(request: Request, user: User = Depends(role_required("Judge"))): + return templates.TemplateResponse("viewall.html", {"request": request}) + +@app.get("/judgecalender.html", response_class=HTMLResponse) +async def judge_calender(request: Request, user: User = Depends(role_required("Judge"))): + return templates.TemplateResponse("judgecalender.html", {"request": request}) + +@app.get("/advocatedashboard.html", response_class=HTMLResponse) +async def advocate_dashboard(request: Request, user: User = Depends(role_required("Advocate/Lawyer"))): + return templates.TemplateResponse("advocatedashboard.html", {"request": request}) + +@app.get("/advocateresources.html", response_class=HTMLResponse) +async def advocate_resources(request: Request, user: User = Depends(role_required("Advocate/Lawyer"))): + return templates.TemplateResponse("advocateresources.html", {"request": request}) + +# ========== Other Role Pages ========== +@app.get("/woman.html", response_class=HTMLResponse) +async def woman_page(request: Request, user: User = Depends(role_required("Woman"))): + return templates.TemplateResponse("woman.html", {"request": request}) + +@app.get("/citizen.html", response_class=HTMLResponse) +async def citizen_page(request: Request, user: User = Depends(role_required("Citizen"))): + return templates.TemplateResponse("citizen.html", {"request": request}) + +@app.get("/minor.html", response_class=HTMLResponse) +async def minor_page(request: Request, user: User = Depends(role_required("Minor"))): + return templates.TemplateResponse("minor.html", {"request": request}) + +# ========== Chatbot Pages ========== +@app.get("/studentchatbot.html", response_class=HTMLResponse) +async def student_page(request: Request, user: User = Depends(role_required("Student"))): + return templates.TemplateResponse("studentchatbot.html", {"request": request}) + +@app.get("/advocatechatbot.html", response_class=HTMLResponse) +async def advocatechatbot_page(request: Request): + return templates.TemplateResponse("advocatechatbot.html", {"request": request}) + +@app.get("/womanchatbot.html", response_class=HTMLResponse) +async def womanchatbot_page(request: Request): + return templates.TemplateResponse("womanchatbot.html", {"request": request}) + + + +@app.get("/safetytips.html", response_class=HTMLResponse) +async def safetytips_page(request: Request): + return templates.TemplateResponse("safetytips.html", {"request": request}) + +@app.get("/resources.html", response_class=HTMLResponse) +async def resources_page(request: Request): + return templates.TemplateResponse("resources.html", {"request": request}) + +@app.get("/legalrights.html", response_class=HTMLResponse) +async def legalrights_page(request: Request): + return templates.TemplateResponse("legalrights.html", {"request": request}) + +@app.get("/FIR.html", response_class=HTMLResponse) +async def fir_page(request: Request): + return templates.TemplateResponse("FIR.html", {"request": request}) + +@app.get("/studentdashboard.html", response_class=HTMLResponse) +async def student_page(request: Request): + return templates.TemplateResponse("studentdashboard.html", {"request": request}) + +# WebSocket for Chatbot +async def stream_text_conversational(websocket: WebSocket, query: str, role: str = "General"): + chat_limit = 10 + temp_chat = {"user": "" ,"system":""} + temp_chat["user"] = query + # print(f"DEBUG: stream_text_conversational executing with role={role}") + model_response = "" + try: + if role == "Citizen": + completion = RAG(query, chat_history, role=role) + else: + completion = RAG(query, chat_history, role=role) + for chunk in completion: + if chunk.choices[0].delta.content is not None: + await websocket.send_text(chunk.choices[0].delta.content) + await asyncio.sleep(0.01) + model_response += chunk.choices[0].delta.content + + # Signal completion to frontend + await websocket.send_text("[DONE]") + + # print(model_response) + temp_chat['system']=model_response + chat_history.append(temp_chat) + if len(chat_history)>chat_limit: + chat_history.pop(0) + except Exception as e: + error_message = f"Error: {str(e)}" + print(f"Chatbot error: {error_message}") + await websocket.send_text(f"\n\n⚠️ {error_message}\n\nPlease check your API key configuration.") + raise + +@app.websocket("/conversational_chat") +async def conversational_chat(websocket: WebSocket, role: Optional[str] = None): + await websocket.accept() + + # --- STRICT ROLE ENFORCEMENT --- + # 1. Get role from Token/Cookie (Source of Truth) + token = websocket.cookies.get("access_token") + token_role = "General" + user_id = None + question_count = 0 + + if token: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if username: + async for db in get_db(): + result = await db.execute(select(User).where(User.username == username)) + user_obj = result.scalars().first() + if user_obj: + token_role = user_obj.role + user_id = user_obj.id + question_count = user_obj.question_count + break + except JWTError: + pass + + if user_id is None: + await websocket.send_text("\n\n⚠️ Authentication Required. Please sign in to use the chatbot.") + await websocket.close() + return + + # 2. Apply Logic based on Source of Truth + if token_role == "Admin": + if not role: + role = "Admin" + else: + role = token_role + + while True: + try: + query = await websocket.receive_text() + + # --- USAGE LIMIT CHECK --- + if token_role != "Admin" and question_count >= 2: + limit_message = ( + "### Free usage limit reached\n\n" + "You’ve reached the free usage limit (2 questions).\n" + "Further access is restricted.\n\n" + "Please contact the administrator for extended access:\n" + "LinkedIn: [https://www.linkedin.com/in/vishwanath77](https://www.linkedin.com/in/vishwanath77)" + ) + await websocket.send_text(limit_message) + continue + + print(f"Query ({role}): {query}") + await stream_text_conversational(websocket, query, role=role) + + # --- INCREMENT USAGE --- + if token_role != "Admin": + async for db in get_db(): + result = await db.execute(select(User).where(User.id == user_id)) + user_to_update = result.scalars().first() + if user_to_update: + user_to_update.question_count += 1 + question_count = user_to_update.question_count # Keep local sync + await db.commit() + break + + except WebSocketDisconnect: + chat_history.clear() + break + except Exception as e: + print(f"WebSocket error: {e}") + try: + await websocket.send_text(f"\n\n❌ Connection error: {str(e)}\n\nPlease try again or contact support.") + except: + pass + break + + +# Run the application +if __name__ == "__main__": + print("Starting Law Bot Server...") + port = int(os.getenv("PORT", 8000)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/src/apps/auth.py b/src/apps/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..74415f44b54ccb417621e77dc5e23ef8ac18f599 --- /dev/null +++ b/src/apps/auth.py @@ -0,0 +1,42 @@ +from datetime import datetime, timedelta +from typing import Optional +import bcrypt + +# Monkeypatch bcrypt for passlib compatibility if needed +if not hasattr(bcrypt, "__about__"): + bcrypt.__about__ = type('About', (object,), {'__version__': bcrypt.__version__}) + +from jose import JWTError, jwt +from passlib.context import CryptContext +import os +from dotenv import load_dotenv + +load_dotenv() + +SECRET_KEY = os.getenv("SECRET_KEY", "your_secret_key") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 30)) + +_pwd_context = None + +def get_pwd_context(): + global _pwd_context + if _pwd_context is None: + _pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + return _pwd_context + +def verify_password(plain_password, hashed_password): + return get_pwd_context().verify(plain_password, hashed_password) + +def get_password_hash(password): + return get_pwd_context().hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt diff --git a/src/apps/database.py b/src/apps/database.py new file mode 100644 index 0000000000000000000000000000000000000000..f3857246fc9b903f1e9fdf9cd71e8d17897b347d --- /dev/null +++ b/src/apps/database.py @@ -0,0 +1,40 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base +import os +from dotenv import load_dotenv + +load_dotenv() + +# Support both DATABASE_URL (Supabase/production) and individual env vars (local dev) +DATABASE_URL = os.getenv("DATABASE_URL") + +if DATABASE_URL: + # Use DATABASE_URL if provided (Supabase/Vercel) + # Convert postgres:// to postgresql+asyncpg:// if needed + if DATABASE_URL.startswith("postgres://"): + DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql+asyncpg://", 1) + elif DATABASE_URL.startswith("postgresql://"): + DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1) +else: + # Fall back to individual environment variables (local development) + DB_USER = os.getenv("DB_USER", "postgres") + DB_PASSWORD = os.getenv("DB_PASSWORD", "password") + DB_HOST = os.getenv("DB_HOST", "localhost") + DB_PORT = os.getenv("DB_PORT", "5432") + DB_NAME = os.getenv("DB_NAME", "lawbot_db") + DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + + +engine = create_async_engine(DATABASE_URL, echo=True) + +AsyncSessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + +Base = declarative_base() + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session diff --git a/src/apps/email_utils.py b/src/apps/email_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..703e965d105196b838a39b7158ce7f2e1da2d627 --- /dev/null +++ b/src/apps/email_utils.py @@ -0,0 +1,65 @@ +from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType +from pydantic import EmailStr +import os +from dotenv import load_dotenv + +load_dotenv() + +BASE_URL = os.getenv("BASE_URL", "http://localhost:8000") + +# Support both SMTP_* and MAIL_* environment variable naming conventions +MAIL_USERNAME = os.getenv("MAIL_USERNAME") or os.getenv("SMTP_USERNAME", "") +MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") or os.getenv("SMTP_PASSWORD", "") +MAIL_FROM = os.getenv("MAIL_FROM") or os.getenv("FROM_EMAIL", "") +MAIL_PORT = int(os.getenv("MAIL_PORT") or os.getenv("SMTP_PORT", "587")) +MAIL_SERVER = os.getenv("MAIL_SERVER") or os.getenv("SMTP_SERVER", "smtp.gmail.com") + +conf = ConnectionConfig( + MAIL_USERNAME=MAIL_USERNAME, + MAIL_PASSWORD=MAIL_PASSWORD, + MAIL_FROM=MAIL_FROM, + MAIL_PORT=MAIL_PORT, + MAIL_SERVER=MAIL_SERVER, + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + USE_CREDENTIALS=True, + VALIDATE_CERTS=True +) + +async def send_reset_email(email: EmailStr, token: str): + # Validate email configuration before attempting to send + if not MAIL_USERNAME or not MAIL_PASSWORD or not MAIL_FROM: + missing_vars = [] + if not MAIL_USERNAME: + missing_vars.append("MAIL_USERNAME or SMTP_USERNAME") + if not MAIL_PASSWORD: + missing_vars.append("MAIL_PASSWORD or SMTP_PASSWORD") + if not MAIL_FROM: + missing_vars.append("MAIL_FROM or FROM_EMAIL") + raise ValueError(f"Email configuration missing: {', '.join(missing_vars)}. Please set these environment variables.") + + reset_link = f"{BASE_URL}/reset-password?token={token}" + html_body = f""" + +
+Hello,
+We received a request to reset your password for your Law Bot account.
+Click the button below to reset your password:
+ Reset Password +Or copy and paste this link into your browser:
+{reset_link}
+If you didn't request this, you can safely ignore this email.
+