Spaces:
Sleeping
Sleeping
Deploy backend updates
Browse files- backend/.env.example +19 -0
- backend/app/config.py +1 -0
- backend/app/database.py +3 -1
- backend/app/dependencies.py +46 -8
- backend/app/main.py +6 -1
- backend/app/models/suggestion_engine.py +102 -0
- backend/app/models/text_classifier_ensemble.py +11 -0
- backend/app/routers/auth_router.py +793 -28
- backend/app/routers/contact_router.py +83 -0
- backend/app/routers/dashboard_router.py +18 -1
- backend/backend_log.txt +0 -0
- backend/download_log.txt +0 -0
- backend/error_out.txt +0 -1
backend/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gmail SMTP settings for contact-form delivery.
|
| 2 |
+
SMTP_HOST=smtp.gmail.com
|
| 3 |
+
SMTP_PORT=587
|
| 4 |
+
SMTP_USER=virdiakash77@gmail.com
|
| 5 |
+
# Use a Google App Password (16 characters), not your normal Gmail password.
|
| 6 |
+
SMTP_PASSWORD=replace_with_google_app_password
|
| 7 |
+
SMTP_FROM=virdiakash77@gmail.com
|
| 8 |
+
CONTACT_EMAIL_TO=virdiakash77@gmail.com
|
| 9 |
+
|
| 10 |
+
# Razorpay payment gateway settings.
|
| 11 |
+
# Use test keys first. Never expose RAZORPAY_KEY_SECRET in frontend code.
|
| 12 |
+
RAZORPAY_KEY_ID=rzp_test_replace_with_key_id
|
| 13 |
+
RAZORPAY_KEY_SECRET=replace_with_razorpay_key_secret
|
| 14 |
+
# Razorpay amount is in paise: 99900 = ₹999.00, 100 = ₹1.00 for testing
|
| 15 |
+
PRO_PLAN_AMOUNT_PAISE=99900
|
| 16 |
+
PRO_PLAN_CURRENCY=INR
|
| 17 |
+
PRO_PLAN_NAME=FakeShield Pro Shield
|
| 18 |
+
# 120 days is roughly 4 months.
|
| 19 |
+
PRO_PLAN_DURATION_DAYS=120
|
backend/app/config.py
CHANGED
|
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
|
|
| 12 |
SMTP_USER: str = ""
|
| 13 |
SMTP_PASSWORD: str = ""
|
| 14 |
SMTP_FROM: str = "fakeshield@yourproject.com"
|
|
|
|
| 15 |
|
| 16 |
# App
|
| 17 |
ENVIRONMENT: str = "development"
|
|
|
|
| 12 |
SMTP_USER: str = ""
|
| 13 |
SMTP_PASSWORD: str = ""
|
| 14 |
SMTP_FROM: str = "fakeshield@yourproject.com"
|
| 15 |
+
CONTACT_EMAIL_TO: str = "virdiakash77@gmail.com"
|
| 16 |
|
| 17 |
# App
|
| 18 |
ENVIRONMENT: str = "development"
|
backend/app/database.py
CHANGED
|
@@ -29,6 +29,7 @@ video_results_collection = DummyCollection("video_forensics")
|
|
| 29 |
audio_results_collection = DummyCollection("audio_forensics")
|
| 30 |
image_results_collection = DummyCollection("image_forensics")
|
| 31 |
text_results_collection = DummyCollection("text_forensics")
|
|
|
|
| 32 |
|
| 33 |
try:
|
| 34 |
# Create the Async MongoDB Client with a short timeout
|
|
@@ -41,6 +42,7 @@ try:
|
|
| 41 |
audio_results_collection = db.get_collection("audio_forensics")
|
| 42 |
image_results_collection = db.get_collection("image_forensics")
|
| 43 |
text_results_collection = db.get_collection("text_forensics")
|
|
|
|
| 44 |
print("[DB] MongoDB Client Initialized (Proxied).")
|
| 45 |
except Exception as e:
|
| 46 |
print(f"[DB] Initial setup error: {e}")
|
|
@@ -58,7 +60,7 @@ async def init_db():
|
|
| 58 |
collections = [
|
| 59 |
users_collection, video_results_collection,
|
| 60 |
audio_results_collection, image_results_collection,
|
| 61 |
-
text_results_collection
|
| 62 |
]
|
| 63 |
|
| 64 |
for col in collections:
|
|
|
|
| 29 |
audio_results_collection = DummyCollection("audio_forensics")
|
| 30 |
image_results_collection = DummyCollection("image_forensics")
|
| 31 |
text_results_collection = DummyCollection("text_forensics")
|
| 32 |
+
activity_logs_collection = DummyCollection("activity_logs")
|
| 33 |
|
| 34 |
try:
|
| 35 |
# Create the Async MongoDB Client with a short timeout
|
|
|
|
| 42 |
audio_results_collection = db.get_collection("audio_forensics")
|
| 43 |
image_results_collection = db.get_collection("image_forensics")
|
| 44 |
text_results_collection = db.get_collection("text_forensics")
|
| 45 |
+
activity_logs_collection = db.get_collection("activity_logs")
|
| 46 |
print("[DB] MongoDB Client Initialized (Proxied).")
|
| 47 |
except Exception as e:
|
| 48 |
print(f"[DB] Initial setup error: {e}")
|
|
|
|
| 60 |
collections = [
|
| 61 |
users_collection, video_results_collection,
|
| 62 |
audio_results_collection, image_results_collection,
|
| 63 |
+
text_results_collection, activity_logs_collection
|
| 64 |
]
|
| 65 |
|
| 66 |
for col in collections:
|
backend/app/dependencies.py
CHANGED
|
@@ -1,11 +1,50 @@
|
|
| 1 |
from fastapi import Header, HTTPException, Depends
|
| 2 |
import jwt
|
| 3 |
import os
|
|
|
|
| 4 |
from app.database import users_collection
|
| 5 |
|
| 6 |
SECRET_KEY = os.getenv("JWT_SECRET", "super-secret-fakeshield-key-for-project")
|
| 7 |
ALGORITHM = "HS256"
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
async def get_current_user(authorization: str = Header(None)):
|
| 10 |
if not authorization:
|
| 11 |
raise HTTPException(status_code=401, detail="Authorization header missing")
|
|
@@ -22,8 +61,7 @@ async def get_current_user(authorization: str = Header(None)):
|
|
| 22 |
try:
|
| 23 |
user = await users_collection.find_one({"email": email})
|
| 24 |
if user is None:
|
| 25 |
-
|
| 26 |
-
print(f"[AUTH] User {email} not in DB. Granting Guest access.")
|
| 27 |
return {
|
| 28 |
"email": email,
|
| 29 |
"full_name": "Guest User",
|
|
@@ -32,18 +70,18 @@ async def get_current_user(authorization: str = Header(None)):
|
|
| 32 |
}
|
| 33 |
return user
|
| 34 |
except Exception as e:
|
| 35 |
-
print(f"[AUTH] DB Error during auth: {e}. Granting
|
| 36 |
return {
|
| 37 |
-
"email":
|
| 38 |
-
"full_name": "Offline Tester",
|
| 39 |
-
"subscription_tier": "
|
| 40 |
"is_offline": True
|
| 41 |
}
|
| 42 |
|
| 43 |
async def verify_paid_tier(user: dict = Depends(get_current_user)):
|
| 44 |
-
if
|
| 45 |
raise HTTPException(
|
| 46 |
status_code=403,
|
| 47 |
-
detail="This feature requires
|
| 48 |
)
|
| 49 |
return user
|
|
|
|
| 1 |
from fastapi import Header, HTTPException, Depends
|
| 2 |
import jwt
|
| 3 |
import os
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
from app.database import users_collection
|
| 6 |
|
| 7 |
SECRET_KEY = os.getenv("JWT_SECRET", "super-secret-fakeshield-key-for-project")
|
| 8 |
ALGORITHM = "HS256"
|
| 9 |
|
| 10 |
+
def _as_utc_datetime(value):
|
| 11 |
+
if value is None:
|
| 12 |
+
return None
|
| 13 |
+
if isinstance(value, datetime):
|
| 14 |
+
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
| 15 |
+
if isinstance(value, str):
|
| 16 |
+
try:
|
| 17 |
+
normalized = value.replace("Z", "+00:00")
|
| 18 |
+
parsed = datetime.fromisoformat(normalized)
|
| 19 |
+
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
|
| 20 |
+
except ValueError:
|
| 21 |
+
return None
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
def is_subscription_active(user: dict) -> bool:
|
| 25 |
+
if user.get("subscription_tier") != "paid":
|
| 26 |
+
return False
|
| 27 |
+
|
| 28 |
+
expires_at = _as_utc_datetime(user.get("subscription_expires_at"))
|
| 29 |
+
if not expires_at:
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
return expires_at > datetime.now(timezone.utc)
|
| 33 |
+
|
| 34 |
+
def serialize_subscription_expires_at(user: dict):
|
| 35 |
+
expires_at = _as_utc_datetime(user.get("subscription_expires_at"))
|
| 36 |
+
return expires_at.isoformat().replace("+00:00", "Z") if expires_at else None
|
| 37 |
+
|
| 38 |
+
def public_user_payload(user: dict) -> dict:
|
| 39 |
+
active = is_subscription_active(user)
|
| 40 |
+
return {
|
| 41 |
+
"name": user.get("fullName") or user.get("name") or user.get("full_name"),
|
| 42 |
+
"email": user.get("email"),
|
| 43 |
+
"subscription_tier": "paid" if active else "free",
|
| 44 |
+
"subscription_expires_at": serialize_subscription_expires_at(user) if active else None,
|
| 45 |
+
"profile_pic": user.get("profile_pic"),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
async def get_current_user(authorization: str = Header(None)):
|
| 49 |
if not authorization:
|
| 50 |
raise HTTPException(status_code=401, detail="Authorization header missing")
|
|
|
|
| 61 |
try:
|
| 62 |
user = await users_collection.find_one({"email": email})
|
| 63 |
if user is None:
|
| 64 |
+
print(f"[AUTH] User {email} not in DB. Granting limited guest access.")
|
|
|
|
| 65 |
return {
|
| 66 |
"email": email,
|
| 67 |
"full_name": "Guest User",
|
|
|
|
| 70 |
}
|
| 71 |
return user
|
| 72 |
except Exception as e:
|
| 73 |
+
print(f"[AUTH] DB Error during auth: {e}. Granting limited guest access.")
|
| 74 |
return {
|
| 75 |
+
"email": email,
|
| 76 |
+
"full_name": "Offline Tester",
|
| 77 |
+
"subscription_tier": "free",
|
| 78 |
"is_offline": True
|
| 79 |
}
|
| 80 |
|
| 81 |
async def verify_paid_tier(user: dict = Depends(get_current_user)):
|
| 82 |
+
if not is_subscription_active(user):
|
| 83 |
raise HTTPException(
|
| 84 |
status_code=403,
|
| 85 |
+
detail="This feature requires an active Pro subscription. Please upgrade to access."
|
| 86 |
)
|
| 87 |
return user
|
backend/app/main.py
CHANGED
|
@@ -90,8 +90,11 @@ def robust_print(msg, **kwargs):
|
|
| 90 |
import transformers
|
| 91 |
transformers.logging.set_verbosity_error()
|
| 92 |
|
|
|
|
| 93 |
from dotenv import load_dotenv
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
|
| 96 |
import os
|
| 97 |
import importlib
|
|
@@ -136,6 +139,7 @@ from app.routers.video_router import router as video_router
|
|
| 136 |
from app.routers.audio_router import router as audio_router
|
| 137 |
from app.routers.auth_router import router as auth_router
|
| 138 |
from app.routers.dashboard_router import router as dashboard_router
|
|
|
|
| 139 |
# Forensic warm-up functions moved to background task to prevent startup hangs
|
| 140 |
# (Imports moved inside the task below)
|
| 141 |
|
|
@@ -161,6 +165,7 @@ app.include_router(video_router, prefix="/api/v1")
|
|
| 161 |
app.include_router(audio_router, prefix="/api/v1")
|
| 162 |
app.include_router(auth_router)
|
| 163 |
app.include_router(dashboard_router)
|
|
|
|
| 164 |
|
| 165 |
import asyncio
|
| 166 |
|
|
|
|
| 90 |
import transformers
|
| 91 |
transformers.logging.set_verbosity_error()
|
| 92 |
|
| 93 |
+
from pathlib import Path
|
| 94 |
from dotenv import load_dotenv
|
| 95 |
+
|
| 96 |
+
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
| 97 |
+
load_dotenv(BACKEND_DIR / ".env")
|
| 98 |
|
| 99 |
import os
|
| 100 |
import importlib
|
|
|
|
| 139 |
from app.routers.audio_router import router as audio_router
|
| 140 |
from app.routers.auth_router import router as auth_router
|
| 141 |
from app.routers.dashboard_router import router as dashboard_router
|
| 142 |
+
from app.routers.contact_router import router as contact_router
|
| 143 |
# Forensic warm-up functions moved to background task to prevent startup hangs
|
| 144 |
# (Imports moved inside the task below)
|
| 145 |
|
|
|
|
| 165 |
app.include_router(audio_router, prefix="/api/v1")
|
| 166 |
app.include_router(auth_router)
|
| 167 |
app.include_router(dashboard_router)
|
| 168 |
+
app.include_router(contact_router)
|
| 169 |
|
| 170 |
import asyncio
|
| 171 |
|
backend/app/models/suggestion_engine.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List
|
| 2 |
+
from app.config import settings
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _build_rule_suggestions(verdict: str, indicators: List[str], word_count: int) -> List[str]:
|
| 6 |
+
verdict = verdict.upper()
|
| 7 |
+
suggestions: List[str] = []
|
| 8 |
+
|
| 9 |
+
if verdict in ("AI GENERATED", "LIKELY AI"):
|
| 10 |
+
suggestions = [
|
| 11 |
+
"Add personal anecdotes, first-person details, and real-world context to break the synthetic pattern.",
|
| 12 |
+
"Vary sentence length and structure to avoid uniform AI-style rhythm.",
|
| 13 |
+
"Use conversational phrasing, contractions, and less formal wording.",
|
| 14 |
+
"Introduce subtle human emotion, sensory detail, or a personal opinion to make the writing feel more authentic."
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
if any("Low perplexity" in ind for ind in indicators):
|
| 18 |
+
suggestions.insert(1, "Increase lexical diversity and avoid repetitive or predictable phrasing.")
|
| 19 |
+
if any("Uniform sentence rhythm" in ind for ind in indicators):
|
| 20 |
+
suggestions.insert(2, "Introduce more irregular sentence flow with natural pauses and variation.")
|
| 21 |
+
if any("Binoculars zero-shot" in ind for ind in indicators):
|
| 22 |
+
suggestions.append("Shift away from statistical-sounding phrasing toward concrete, humanized language.")
|
| 23 |
+
|
| 24 |
+
elif verdict == "UNCERTAIN":
|
| 25 |
+
suggestions = [
|
| 26 |
+
"If you want a more human voice, add unique details, varied tone, and irregular sentence cadence.",
|
| 27 |
+
"If you want a polished AI-style tone, make the language more consistent and formal.",
|
| 28 |
+
"Use vivid examples and conversational transitions to reduce ambiguity.",
|
| 29 |
+
"Balance structure with occasional human-style breaks such as rhetorical questions or shorter sentences."
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
else:
|
| 33 |
+
suggestions = [
|
| 34 |
+
"Preserve your natural voice and sentence variety; this supports a human writing profile.",
|
| 35 |
+
"Use concrete examples, specific context, and varied punctuation for authentic human style.",
|
| 36 |
+
"Keep the dynamic rhythm and lexical richness that make the text feel organic.",
|
| 37 |
+
"Avoid overly formal or repetitive phrases unless you want a polished editorial tone."
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
if any("High linguistic entropy" in ind for ind in indicators):
|
| 41 |
+
suggestions.insert(1, "Keep the rich vocabulary and creative phrasing that signal human authorship.")
|
| 42 |
+
if any("Dynamic rhythmic variance" in ind for ind in indicators):
|
| 43 |
+
suggestions.append("Preserve the irregular sentence cadence that gives this text a natural flow.")
|
| 44 |
+
|
| 45 |
+
if word_count < 150:
|
| 46 |
+
suggestions.append("Use more than 150 words for a more reliable forensic assessment.")
|
| 47 |
+
|
| 48 |
+
return suggestions
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _generate_ai_suggestions(text: str, verdict: str, indicators: List[str]) -> List[str]:
|
| 52 |
+
if not settings.GEMINI_API_KEY or len(text.strip()) < 80:
|
| 53 |
+
return []
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
import google.generativeai as genai
|
| 57 |
+
genai.configure(api_key=settings.GEMINI_API_KEY)
|
| 58 |
+
|
| 59 |
+
model_name = settings.GEMINI_MODEL or "gemini-2.0-flash"
|
| 60 |
+
model = genai.GenerativeModel(model_name)
|
| 61 |
+
|
| 62 |
+
prompt = (
|
| 63 |
+
"You are a practical writing assistant. Based on the following text verdict and indicators, "
|
| 64 |
+
"provide 4 short improvement suggestions. Return only a JSON array of strings.\n\n"
|
| 65 |
+
f"Verdict: {verdict}\n"
|
| 66 |
+
f"Indicators: {', '.join(indicators) or 'none'}\n"
|
| 67 |
+
"Text sample: """" + text[:1200] + """"\n"
|
| 68 |
+
"JSON:"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
response = model.generate_content(
|
| 72 |
+
prompt,
|
| 73 |
+
generation_config={"max_output_tokens": 180, "temperature": 0.7}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
raw = response.text.strip()
|
| 77 |
+
raw = raw.replace('```json', '').replace('```', '').strip()
|
| 78 |
+
|
| 79 |
+
import json
|
| 80 |
+
suggestions = json.loads(raw)
|
| 81 |
+
if isinstance(suggestions, list) and all(isinstance(item, str) for item in suggestions):
|
| 82 |
+
return suggestions
|
| 83 |
+
except Exception:
|
| 84 |
+
pass
|
| 85 |
+
|
| 86 |
+
return []
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def generate_text_improvement_suggestions(result: Dict[str, Any], text: str = "") -> List[str]:
|
| 90 |
+
verdict = str(result.get("verdict", "UNCERTAIN")).upper()
|
| 91 |
+
indicators = result.get("indicators") or []
|
| 92 |
+
if not isinstance(indicators, list):
|
| 93 |
+
indicators = [str(indicators)]
|
| 94 |
+
|
| 95 |
+
word_count = result.get("word_count") or len(text.split())
|
| 96 |
+
suggestions = _build_rule_suggestions(verdict, [str(i) for i in indicators], word_count)
|
| 97 |
+
|
| 98 |
+
ai_suggestions = _generate_ai_suggestions(text, verdict, [str(i) for i in indicators])
|
| 99 |
+
if ai_suggestions:
|
| 100 |
+
return ai_suggestions
|
| 101 |
+
|
| 102 |
+
return suggestions
|
backend/app/models/text_classifier_ensemble.py
CHANGED
|
@@ -19,6 +19,7 @@ from transformers import AutoTokenizer, AutoModelForSequenceClassification, GPT2
|
|
| 19 |
from sentence_transformers import SentenceTransformer
|
| 20 |
from scipy.spatial.distance import cosine
|
| 21 |
from app.config import settings
|
|
|
|
| 22 |
|
| 23 |
# --- Internal Engines ---
|
| 24 |
from app.models.binoculars import Binoculars
|
|
@@ -453,6 +454,15 @@ def ensemble_predict(text: str, mode: str = "v14") -> Dict[str, Any]:
|
|
| 453 |
"perplexity": float(gpt2_res["raw_perplexity"]) # Global proxy
|
| 454 |
})
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
return {
|
| 457 |
"scan_id": f"fs-v14-{os.urandom(4).hex()}",
|
| 458 |
"verdict": verdict,
|
|
@@ -467,6 +477,7 @@ def ensemble_predict(text: str, mode: str = "v14") -> Dict[str, Any]:
|
|
| 467 |
"word_count": word_count,
|
| 468 |
"engine_version": "v14.0-Elite-Classic",
|
| 469 |
"sentence_highlights": highlights,
|
|
|
|
| 470 |
"structural_details": {
|
| 471 |
"avg_depth": 0, "depth_variance": round(depth_variance, 2),
|
| 472 |
"structural_entropy": round(gpt2_res["raw_perplexity"], 2),
|
|
|
|
| 19 |
from sentence_transformers import SentenceTransformer
|
| 20 |
from scipy.spatial.distance import cosine
|
| 21 |
from app.config import settings
|
| 22 |
+
from app.models.suggestion_engine import generate_text_improvement_suggestions
|
| 23 |
|
| 24 |
# --- Internal Engines ---
|
| 25 |
from app.models.binoculars import Binoculars
|
|
|
|
| 454 |
"perplexity": float(gpt2_res["raw_perplexity"]) # Global proxy
|
| 455 |
})
|
| 456 |
|
| 457 |
+
improvement_suggestions = generate_text_improvement_suggestions(
|
| 458 |
+
{
|
| 459 |
+
"verdict": verdict,
|
| 460 |
+
"indicators": indicators,
|
| 461 |
+
"word_count": word_count,
|
| 462 |
+
},
|
| 463 |
+
text
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
return {
|
| 467 |
"scan_id": f"fs-v14-{os.urandom(4).hex()}",
|
| 468 |
"verdict": verdict,
|
|
|
|
| 477 |
"word_count": word_count,
|
| 478 |
"engine_version": "v14.0-Elite-Classic",
|
| 479 |
"sentence_highlights": highlights,
|
| 480 |
+
"improvement_suggestions": improvement_suggestions,
|
| 481 |
"structural_details": {
|
| 482 |
"avg_depth": 0, "depth_variance": round(depth_variance, 2),
|
| 483 |
"structural_entropy": round(gpt2_res["raw_perplexity"], 2),
|
backend/app/routers/auth_router.py
CHANGED
|
@@ -1,22 +1,76 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException, Depends, status
|
| 2 |
from pymongo.errors import ServerSelectionTimeoutError
|
| 3 |
|
| 4 |
from pydantic import BaseModel, EmailStr
|
| 5 |
from typing import Optional
|
| 6 |
from passlib.context import CryptContext
|
| 7 |
from datetime import datetime, timedelta
|
|
|
|
| 8 |
import jwt
|
| 9 |
import os
|
| 10 |
import httpx
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
|
| 15 |
|
| 16 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 17 |
SECRET_KEY = os.getenv("JWT_SECRET", "super-secret-fakeshield-key-for-project")
|
| 18 |
ALGORITHM = "HS256"
|
| 19 |
-
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
class UserSignup(BaseModel):
|
| 22 |
fullName: str
|
|
@@ -26,6 +80,19 @@ class UserSignup(BaseModel):
|
|
| 26 |
class UserLogin(BaseModel):
|
| 27 |
email: EmailStr
|
| 28 |
password: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
class OAuthLogin(BaseModel):
|
| 31 |
provider: str
|
|
@@ -34,15 +101,29 @@ class OAuthLogin(BaseModel):
|
|
| 34 |
profile_pic: Optional[str] = None
|
| 35 |
code: Optional[str] = None
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def verify_password(plain_password, hashed_password):
|
| 38 |
return pwd_context.verify(plain_password, hashed_password)
|
| 39 |
|
| 40 |
def get_password_hash(password):
|
| 41 |
return pwd_context.hash(password)
|
| 42 |
|
| 43 |
-
def create_access_token(data: dict):
|
| 44 |
to_encode = data.copy()
|
| 45 |
-
|
|
|
|
| 46 |
to_encode.update({"exp": expire})
|
| 47 |
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 48 |
if isinstance(encoded_jwt, bytes):
|
|
@@ -50,8 +131,8 @@ def create_access_token(data: dict):
|
|
| 50 |
return encoded_jwt
|
| 51 |
|
| 52 |
def get_subscription_tier(email: str):
|
| 53 |
-
|
| 54 |
-
return "
|
| 55 |
|
| 56 |
@router.post("/signup")
|
| 57 |
async def signup(user: UserSignup):
|
|
@@ -82,6 +163,27 @@ async def signup(user: UserSignup):
|
|
| 82 |
|
| 83 |
await users_collection.insert_one(user_dict)
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# Generate token
|
| 86 |
access_token = create_access_token(data={"sub": user.email})
|
| 87 |
return {
|
|
@@ -94,22 +196,65 @@ async def signup(user: UserSignup):
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
@router.post("/login")
|
| 98 |
-
async def login(user: UserLogin):
|
| 99 |
try:
|
| 100 |
db_user = await users_collection.find_one({"email": user.email})
|
| 101 |
except Exception as e:
|
| 102 |
# DB offline — issue an offline JWT so the user can still use the app
|
| 103 |
print(f"[AUTH] DB offline during login: {e}. Issuing offline token.", flush=True)
|
| 104 |
tier = get_subscription_tier(user.email)
|
| 105 |
-
access_token = create_access_token(data={"sub": user.email})
|
| 106 |
return {
|
| 107 |
"access_token": access_token,
|
| 108 |
"token_type": "bearer",
|
|
|
|
| 109 |
"user": {
|
| 110 |
"name": user.email.split("@")[0].title(),
|
| 111 |
"email": user.email,
|
| 112 |
-
"subscription_tier": "
|
| 113 |
}
|
| 114 |
}
|
| 115 |
|
|
@@ -128,10 +273,32 @@ async def login(user: UserLogin):
|
|
| 128 |
except:
|
| 129 |
pass
|
| 130 |
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
return {
|
| 133 |
"access_token": access_token,
|
| 134 |
-
"token_type": "bearer",
|
|
|
|
| 135 |
"user": {
|
| 136 |
"name": db_user["fullName"],
|
| 137 |
"email": db_user["email"],
|
|
@@ -139,8 +306,98 @@ async def login(user: UserLogin):
|
|
| 139 |
}
|
| 140 |
}
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
@router.post("/oauth")
|
| 143 |
-
async def oauth_login(oauth_data: dict):
|
| 144 |
"""
|
| 145 |
OAuth endpoint for Github/Google.
|
| 146 |
Using a raw dict to bypass persistent validation errors.
|
|
@@ -150,6 +407,7 @@ async def oauth_login(oauth_data: dict):
|
|
| 150 |
email = oauth_data.get("email")
|
| 151 |
name = oauth_data.get("name")
|
| 152 |
profile_pic = oauth_data.get("profile_pic")
|
|
|
|
| 153 |
|
| 154 |
print(f"[AUTH] OAuth Request: provider={provider}, email={email}, name={name}, has_code={bool(code)}", flush=True)
|
| 155 |
|
|
@@ -212,8 +470,9 @@ async def oauth_login(oauth_data: dict):
|
|
| 212 |
if not db_user:
|
| 213 |
# Auto-signup OAuth users
|
| 214 |
tier = get_subscription_tier(email)
|
|
|
|
| 215 |
user_dict = {
|
| 216 |
-
"fullName":
|
| 217 |
"email": email,
|
| 218 |
"auth_provider": provider,
|
| 219 |
"profile_pic": profile_pic,
|
|
@@ -222,6 +481,27 @@ async def oauth_login(oauth_data: dict):
|
|
| 222 |
}
|
| 223 |
await users_collection.insert_one(user_dict)
|
| 224 |
db_user = user_dict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
else:
|
| 226 |
# Update profile info if changed
|
| 227 |
update_data = {"auth_provider": provider}
|
|
@@ -233,11 +513,31 @@ async def oauth_login(oauth_data: dict):
|
|
| 233 |
|
| 234 |
await users_collection.update_one({"_id": db_user["_id"]}, {"$set": update_data})
|
| 235 |
db_user.update(update_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
except Exception as e:
|
| 237 |
print(f"[AUTH] DB offline during OAuth: {e}. Issuing offline session.", flush=True)
|
| 238 |
# DB offline — issue an offline JWT session
|
| 239 |
tier = get_subscription_tier(email)
|
| 240 |
-
access_token = create_access_token(data={"sub": email})
|
| 241 |
return {
|
| 242 |
"access_token": access_token,
|
| 243 |
"token_type": "bearer",
|
|
@@ -245,11 +545,11 @@ async def oauth_login(oauth_data: dict):
|
|
| 245 |
"name": name or email.split("@")[0].title(),
|
| 246 |
"email": email,
|
| 247 |
"profile_pic": profile_pic,
|
| 248 |
-
"subscription_tier": "
|
| 249 |
}
|
| 250 |
}
|
| 251 |
|
| 252 |
-
access_token = create_access_token(data={"sub": db_user["email"]})
|
| 253 |
return {
|
| 254 |
"access_token": access_token,
|
| 255 |
"token_type": "bearer",
|
|
@@ -261,30 +561,495 @@ async def oauth_login(oauth_data: dict):
|
|
| 261 |
}
|
| 262 |
}
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
result = await users_collection.update_one(
|
| 268 |
{"email": email},
|
| 269 |
-
{"$set": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
)
|
| 271 |
if result.modified_count == 0:
|
| 272 |
raise HTTPException(status_code=404, detail="User not found")
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
@router.get("/me")
|
| 276 |
async def get_me(user: dict = Depends(get_current_user)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
return {
|
| 278 |
-
"
|
| 279 |
-
"
|
| 280 |
-
"
|
| 281 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
@router.get("/test")
|
| 285 |
async def auth_test():
|
| 286 |
return {
|
| 287 |
"message": "Auth router is reachable!",
|
| 288 |
-
"version": "production-oauth-
|
| 289 |
"handshake_type": "raw_dict"
|
| 290 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
| 2 |
from pymongo.errors import ServerSelectionTimeoutError
|
| 3 |
|
| 4 |
from pydantic import BaseModel, EmailStr
|
| 5 |
from typing import Optional
|
| 6 |
from passlib.context import CryptContext
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
+
from pathlib import Path
|
| 9 |
import jwt
|
| 10 |
import os
|
| 11 |
import httpx
|
| 12 |
+
import hmac
|
| 13 |
+
import hashlib
|
| 14 |
+
import secrets
|
| 15 |
+
import aiosmtplib
|
| 16 |
+
import asyncio
|
| 17 |
+
from email.mime.multipart import MIMEMultipart
|
| 18 |
+
from email.mime.text import MIMEText
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
from app.database import (
|
| 21 |
+
users_collection,
|
| 22 |
+
activity_logs_collection,
|
| 23 |
+
video_results_collection,
|
| 24 |
+
audio_results_collection,
|
| 25 |
+
image_results_collection,
|
| 26 |
+
text_results_collection
|
| 27 |
+
)
|
| 28 |
+
from app.dependencies import get_current_user, public_user_payload
|
| 29 |
+
|
| 30 |
+
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
| 31 |
+
load_dotenv(BACKEND_DIR / ".env")
|
| 32 |
|
| 33 |
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
|
| 34 |
|
| 35 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 36 |
SECRET_KEY = os.getenv("JWT_SECRET", "super-secret-fakeshield-key-for-project")
|
| 37 |
ALGORITHM = "HS256"
|
| 38 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days (normal)
|
| 39 |
+
REMEMBER_ME_EXPIRE_MINUTES = 60 * 24 * 30 # 30 days (remember-me)
|
| 40 |
+
OTP_EXPIRE_MINUTES = 15
|
| 41 |
+
|
| 42 |
+
# ── Universal SMTP Email Configuration (12-Factor App Standard) ──
|
| 43 |
+
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
| 44 |
+
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
| 45 |
+
SMTP_USER = os.getenv("SMTP_USER", "")
|
| 46 |
+
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
| 47 |
+
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
|
| 48 |
+
|
| 49 |
+
async def send_email(to_email: str, subject: str, html_body: str):
|
| 50 |
+
"""Industry-standard async SMTP delivery."""
|
| 51 |
+
if not SMTP_USER or not SMTP_PASSWORD:
|
| 52 |
+
print(f"[MAIL WARN] SMTP credentials missing. To: {to_email} | Sub: {subject}")
|
| 53 |
+
print(f"[MAIL CONSOLE DEV MODE]\n{html_body}\n")
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
msg = MIMEMultipart("alternative")
|
| 57 |
+
msg["Subject"] = subject
|
| 58 |
+
msg["From"] = f"FakeShield <{SMTP_FROM}>"
|
| 59 |
+
msg["To"] = to_email
|
| 60 |
+
msg.attach(MIMEText(html_body, "html"))
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
await aiosmtplib.send(
|
| 64 |
+
msg,
|
| 65 |
+
hostname=SMTP_HOST,
|
| 66 |
+
port=SMTP_PORT,
|
| 67 |
+
start_tls=True,
|
| 68 |
+
username=SMTP_USER,
|
| 69 |
+
password=SMTP_PASSWORD,
|
| 70 |
+
)
|
| 71 |
+
print(f"[MAIL LOG] Successfully delivered email to {to_email}")
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"[MAIL ERROR] Failed to send email via SMTP: {e}")
|
| 74 |
|
| 75 |
class UserSignup(BaseModel):
|
| 76 |
fullName: str
|
|
|
|
| 80 |
class UserLogin(BaseModel):
|
| 81 |
email: EmailStr
|
| 82 |
password: str
|
| 83 |
+
remember_me: bool = False
|
| 84 |
+
|
| 85 |
+
class ForgotPasswordRequest(BaseModel):
|
| 86 |
+
email: EmailStr
|
| 87 |
+
|
| 88 |
+
class ResetPasswordRequest(BaseModel):
|
| 89 |
+
token: str
|
| 90 |
+
new_password: str
|
| 91 |
+
|
| 92 |
+
class UpdatePreferencesRequest(BaseModel):
|
| 93 |
+
email_scan_complete: bool
|
| 94 |
+
email_suspicious_login: bool
|
| 95 |
+
email_monthly_report: bool
|
| 96 |
|
| 97 |
class OAuthLogin(BaseModel):
|
| 98 |
provider: str
|
|
|
|
| 101 |
profile_pic: Optional[str] = None
|
| 102 |
code: Optional[str] = None
|
| 103 |
|
| 104 |
+
class UpdateProfileRequest(BaseModel):
|
| 105 |
+
fullName: Optional[str] = None
|
| 106 |
+
email: Optional[EmailStr] = None
|
| 107 |
+
|
| 108 |
+
class ChangePasswordConfirmRequest(BaseModel):
|
| 109 |
+
new_password: str
|
| 110 |
+
otp: str
|
| 111 |
+
|
| 112 |
+
class RazorpayVerifyRequest(BaseModel):
|
| 113 |
+
razorpay_order_id: str
|
| 114 |
+
razorpay_payment_id: str
|
| 115 |
+
razorpay_signature: str
|
| 116 |
+
|
| 117 |
def verify_password(plain_password, hashed_password):
|
| 118 |
return pwd_context.verify(plain_password, hashed_password)
|
| 119 |
|
| 120 |
def get_password_hash(password):
|
| 121 |
return pwd_context.hash(password)
|
| 122 |
|
| 123 |
+
def create_access_token(data: dict, remember_me: bool = False):
|
| 124 |
to_encode = data.copy()
|
| 125 |
+
minutes = REMEMBER_ME_EXPIRE_MINUTES if remember_me else ACCESS_TOKEN_EXPIRE_MINUTES
|
| 126 |
+
expire = datetime.utcnow() + timedelta(minutes=minutes)
|
| 127 |
to_encode.update({"exp": expire})
|
| 128 |
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 129 |
if isinstance(encoded_jwt, bytes):
|
|
|
|
| 131 |
return encoded_jwt
|
| 132 |
|
| 133 |
def get_subscription_tier(email: str):
|
| 134 |
+
# New accounts start free. Pro is activated only after Razorpay verification.
|
| 135 |
+
return "free"
|
| 136 |
|
| 137 |
@router.post("/signup")
|
| 138 |
async def signup(user: UserSignup):
|
|
|
|
| 163 |
|
| 164 |
await users_collection.insert_one(user_dict)
|
| 165 |
|
| 166 |
+
# Send Welcome Email
|
| 167 |
+
welcome_body = f"""
|
| 168 |
+
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;border-radius:2rem;background:#0f1117;color:#e2e8f0;border:1px solid #1e293b;text-align:center">
|
| 169 |
+
<div style="width:56px;height:56px;border-radius:1rem;background:linear-gradient(135deg, #00E5CC, #8b5cf6);margin:0 auto 20px auto;color:#ffffff;font-size:24px;font-weight:bold;line-height:56px">FS</div>
|
| 170 |
+
<h2 style="color:#00E5CC;margin-bottom:12px">Welcome to FakeShield!</h2>
|
| 171 |
+
<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin-bottom:24px">
|
| 172 |
+
Hey <strong>{user.fullName}</strong>, thank you for joining the FakeShield Forensic Suite. Your account associated with <strong>{user.email}</strong> is now active.
|
| 173 |
+
</p>
|
| 174 |
+
<div style="background:#1e2130;border-radius:1rem;padding:20px;text-align:left;font-size:13px;color:#cbd5e1">
|
| 175 |
+
<div style="margin-bottom:8px"><strong>🛡️ Forensic Tools Available:</strong></div>
|
| 176 |
+
<ul style="margin:0;padding-left:16px;color:#94a3b8;line-height:1.5">
|
| 177 |
+
<li>Deepfake Video & Audio Detection</li>
|
| 178 |
+
<li>Metadata & Forensic Lens analysis</li>
|
| 179 |
+
<li>Detailed PDF Reports generation</li>
|
| 180 |
+
</ul>
|
| 181 |
+
</div>
|
| 182 |
+
<p style="margin-top:24px;color:#64748b;font-size:11px">If you did not create this account, please contact support immediately.</p>
|
| 183 |
+
</div>
|
| 184 |
+
"""
|
| 185 |
+
asyncio.create_task(send_email(user.email, "Welcome to FakeShield!", welcome_body))
|
| 186 |
+
|
| 187 |
# Generate token
|
| 188 |
access_token = create_access_token(data={"sub": user.email})
|
| 189 |
return {
|
|
|
|
| 196 |
}
|
| 197 |
}
|
| 198 |
|
| 199 |
+
def parse_user_agent(ua_string: str) -> str:
|
| 200 |
+
ua = ua_string.lower()
|
| 201 |
+
os_name = "Unknown"
|
| 202 |
+
browser = "Unknown"
|
| 203 |
+
if "windows" in ua: os_name = "Windows"
|
| 204 |
+
elif "mac" in ua: os_name = "macOS"
|
| 205 |
+
elif "linux" in ua: os_name = "Linux"
|
| 206 |
+
elif "iphone" in ua or "ipad" in ua: os_name = "iOS"
|
| 207 |
+
elif "android" in ua: os_name = "Android"
|
| 208 |
+
|
| 209 |
+
if "edg" in ua: browser = "Edge"
|
| 210 |
+
elif "chrome" in ua: browser = "Chrome"
|
| 211 |
+
elif "safari" in ua and "chrome" not in ua: browser = "Safari"
|
| 212 |
+
elif "firefox" in ua: browser = "Firefox"
|
| 213 |
+
|
| 214 |
+
if os_name == "Unknown" and browser == "Unknown":
|
| 215 |
+
return "Unknown Device"
|
| 216 |
+
return f"{browser} on {os_name}"
|
| 217 |
+
|
| 218 |
+
async def log_user_activity(email: str, event: str, request: Request):
|
| 219 |
+
ip = request.client.host if request and request.client else "Unknown IP"
|
| 220 |
+
if ip in ["127.0.0.1", "::1", "localhost", "Unknown IP"]:
|
| 221 |
+
location = "Local Network"
|
| 222 |
+
else:
|
| 223 |
+
location = "Mumbai, IN" # Mock default for public IPs mapped in demo
|
| 224 |
+
|
| 225 |
+
ua_raw = request.headers.get("user-agent", "")
|
| 226 |
+
device_os = parse_user_agent(ua_raw)
|
| 227 |
+
|
| 228 |
+
activity = {
|
| 229 |
+
"email": email,
|
| 230 |
+
"event": event,
|
| 231 |
+
"ip": ip,
|
| 232 |
+
"device": device_os,
|
| 233 |
+
"location": location,
|
| 234 |
+
"timestamp": datetime.utcnow()
|
| 235 |
+
}
|
| 236 |
+
try:
|
| 237 |
+
await activity_logs_collection.insert_one(activity)
|
| 238 |
+
except:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
@router.post("/login")
|
| 242 |
+
async def login(user: UserLogin, request: Request):
|
| 243 |
try:
|
| 244 |
db_user = await users_collection.find_one({"email": user.email})
|
| 245 |
except Exception as e:
|
| 246 |
# DB offline — issue an offline JWT so the user can still use the app
|
| 247 |
print(f"[AUTH] DB offline during login: {e}. Issuing offline token.", flush=True)
|
| 248 |
tier = get_subscription_tier(user.email)
|
| 249 |
+
access_token = create_access_token(data={"sub": user.email}, remember_me=user.remember_me)
|
| 250 |
return {
|
| 251 |
"access_token": access_token,
|
| 252 |
"token_type": "bearer",
|
| 253 |
+
"remember_me": user.remember_me,
|
| 254 |
"user": {
|
| 255 |
"name": user.email.split("@")[0].title(),
|
| 256 |
"email": user.email,
|
| 257 |
+
"subscription_tier": "free"
|
| 258 |
}
|
| 259 |
}
|
| 260 |
|
|
|
|
| 273 |
except:
|
| 274 |
pass
|
| 275 |
|
| 276 |
+
# Send Successful Login Alert
|
| 277 |
+
login_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 278 |
+
login_body = f"""
|
| 279 |
+
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;border-radius:2rem;background:#0f1117;color:#e2e8f0;border:1px solid #1e293b">
|
| 280 |
+
<div style="width:56px;height:56px;border-radius:1rem;background:linear-gradient(135deg, #00E5CC, #8b5cf6);margin:0 auto 20px auto;color:#ffffff;font-size:24px;font-weight:bold;line-height:56px;text-align:center">FS</div>
|
| 281 |
+
<h2 style="color:#00E5CC;margin-bottom:12px;text-align:center">Successful Login Security Alert</h2>
|
| 282 |
+
<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin-bottom:24px;text-align:center">
|
| 283 |
+
We detected a successful login to your FakeShield account (<strong>{db_user["email"]}</strong>).
|
| 284 |
+
</p>
|
| 285 |
+
<div style="background:#1e2130;border-radius:1rem;padding:20px;font-size:13px;color:#cbd5e1">
|
| 286 |
+
<div style="margin-bottom:6px"><strong>Log Event Details:</strong></div>
|
| 287 |
+
<div style="color:#94a3b8;margin-bottom:4px"><strong>Time:</strong> {login_time} UTC</div>
|
| 288 |
+
<div style="color:#94a3b8"><strong>Method:</strong> Password & Email Authentication</div>
|
| 289 |
+
</div>
|
| 290 |
+
<p style="margin-top:24px;color:#64748b;font-size:11px;text-align:center">If this was you, no action is required. If you do not recognize this login, please reset your password immediately.</p>
|
| 291 |
+
</div>
|
| 292 |
+
"""
|
| 293 |
+
asyncio.create_task(send_email(db_user["email"], "FakeShield Security Alert: Login Detected", login_body))
|
| 294 |
+
|
| 295 |
+
await log_user_activity(db_user["email"], "Success Login", request)
|
| 296 |
+
|
| 297 |
+
access_token = create_access_token(data={"sub": db_user["email"]}, remember_me=user.remember_me)
|
| 298 |
return {
|
| 299 |
"access_token": access_token,
|
| 300 |
+
"token_type": "bearer",
|
| 301 |
+
"remember_me": user.remember_me,
|
| 302 |
"user": {
|
| 303 |
"name": db_user["fullName"],
|
| 304 |
"email": db_user["email"],
|
|
|
|
| 306 |
}
|
| 307 |
}
|
| 308 |
|
| 309 |
+
|
| 310 |
+
# ── Forgot Password ────────────────────────────────────────────────────────
|
| 311 |
+
@router.post("/forgot-password")
|
| 312 |
+
async def forgot_password(req: ForgotPasswordRequest):
|
| 313 |
+
"""
|
| 314 |
+
Generate a one-time 6-digit OTP, store it hashed in MongoDB with an
|
| 315 |
+
expiry, and email it to the user. Always returns 200 so we don't
|
| 316 |
+
leak whether an account exists.
|
| 317 |
+
"""
|
| 318 |
+
try:
|
| 319 |
+
db_user = await users_collection.find_one({"email": req.email})
|
| 320 |
+
except Exception as e:
|
| 321 |
+
print(f"[AUTH] DB offline during forgot-password: {e}")
|
| 322 |
+
# Pretend we sent it – no data to work with offline
|
| 323 |
+
return {"message": "If that email exists, a reset code has been sent."}
|
| 324 |
+
|
| 325 |
+
if db_user:
|
| 326 |
+
otp = str(secrets.randbelow(900000) + 100000) # 6-digit OTP
|
| 327 |
+
otp_hash = pwd_context.hash(otp)
|
| 328 |
+
otp_expiry = datetime.utcnow() + timedelta(minutes=OTP_EXPIRE_MINUTES)
|
| 329 |
+
|
| 330 |
+
await users_collection.update_one(
|
| 331 |
+
{"email": req.email},
|
| 332 |
+
{"$set": {"reset_otp": otp_hash, "reset_otp_expiry": otp_expiry}}
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
html_body = f"""
|
| 336 |
+
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;border-radius:12px;background:#0f1117;color:#e2e8f0">
|
| 337 |
+
<h2 style="color:#00E5CC;margin-bottom:8px">FakeShield Password Reset</h2>
|
| 338 |
+
<p style="margin-bottom:24px;color:#94a3b8">Use the code below to reset your password. It expires in <strong>{OTP_EXPIRE_MINUTES} minutes</strong>.</p>
|
| 339 |
+
<div style="background:#1e2130;border-radius:12px;padding:24px;text-align:center">
|
| 340 |
+
<span style="font-size:40px;font-weight:bold;letter-spacing:12px;color:#00E5CC">{otp}</span>
|
| 341 |
+
</div>
|
| 342 |
+
<p style="margin-top:24px;color:#64748b;font-size:12px">If you did not request this, you can safely ignore this email.</p>
|
| 343 |
+
</div>
|
| 344 |
+
"""
|
| 345 |
+
await send_email(req.email, "Your FakeShield Reset Code", html_body)
|
| 346 |
+
|
| 347 |
+
return {"message": "If that email exists, a reset code has been sent."}
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# ── Reset Password ─────────────────────────────────────────────────────────
|
| 351 |
+
@router.post("/reset-password")
|
| 352 |
+
async def reset_password(req: ResetPasswordRequest):
|
| 353 |
+
"""
|
| 354 |
+
Verify the OTP and set a new password.
|
| 355 |
+
"""
|
| 356 |
+
if len(req.new_password) < 8:
|
| 357 |
+
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
|
| 358 |
+
|
| 359 |
+
try:
|
| 360 |
+
# Find the specific user whose OTP has not expired yet
|
| 361 |
+
db_user = await users_collection.find_one({
|
| 362 |
+
"email": req.email,
|
| 363 |
+
"reset_otp": {"$exists": True},
|
| 364 |
+
"reset_otp_expiry": {"$gt": datetime.utcnow()}
|
| 365 |
+
})
|
| 366 |
+
except Exception as e:
|
| 367 |
+
raise HTTPException(status_code=503, detail="Database unavailable. Try again later.")
|
| 368 |
+
|
| 369 |
+
if not db_user:
|
| 370 |
+
raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
|
| 371 |
+
|
| 372 |
+
# Verify OTP
|
| 373 |
+
if not pwd_context.verify(req.token, db_user["reset_otp"]):
|
| 374 |
+
raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
|
| 375 |
+
|
| 376 |
+
# Update password, clear OTP fields, and set auth_provider to local
|
| 377 |
+
new_hash = get_password_hash(req.new_password)
|
| 378 |
+
await users_collection.update_one(
|
| 379 |
+
{"_id": db_user["_id"]},
|
| 380 |
+
{
|
| 381 |
+
"$set": {"password": new_hash, "auth_provider": "local"},
|
| 382 |
+
"$unset": {"reset_otp": "", "reset_otp_expiry": ""}
|
| 383 |
+
}
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
# Issue a fresh token so the user is automatically logged in
|
| 387 |
+
access_token = create_access_token(data={"sub": db_user["email"]})
|
| 388 |
+
return {
|
| 389 |
+
"message": "Password reset successfully.",
|
| 390 |
+
"access_token": access_token,
|
| 391 |
+
"token_type": "bearer",
|
| 392 |
+
"user": {
|
| 393 |
+
"name": db_user.get("fullName", db_user["email"].split("@")[0].title()),
|
| 394 |
+
"email": db_user["email"],
|
| 395 |
+
"subscription_tier": db_user.get("subscription_tier", "free")
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
@router.post("/oauth")
|
| 400 |
+
async def oauth_login(oauth_data: dict, request: Request):
|
| 401 |
"""
|
| 402 |
OAuth endpoint for Github/Google.
|
| 403 |
Using a raw dict to bypass persistent validation errors.
|
|
|
|
| 407 |
email = oauth_data.get("email")
|
| 408 |
name = oauth_data.get("name")
|
| 409 |
profile_pic = oauth_data.get("profile_pic")
|
| 410 |
+
remember_me = oauth_data.get("remember_me") is True
|
| 411 |
|
| 412 |
print(f"[AUTH] OAuth Request: provider={provider}, email={email}, name={name}, has_code={bool(code)}", flush=True)
|
| 413 |
|
|
|
|
| 470 |
if not db_user:
|
| 471 |
# Auto-signup OAuth users
|
| 472 |
tier = get_subscription_tier(email)
|
| 473 |
+
fullName = name or email.split("@")[0].title()
|
| 474 |
user_dict = {
|
| 475 |
+
"fullName": fullName,
|
| 476 |
"email": email,
|
| 477 |
"auth_provider": provider,
|
| 478 |
"profile_pic": profile_pic,
|
|
|
|
| 481 |
}
|
| 482 |
await users_collection.insert_one(user_dict)
|
| 483 |
db_user = user_dict
|
| 484 |
+
|
| 485 |
+
# Send welcome email asynchronously
|
| 486 |
+
welcome_body = f"""
|
| 487 |
+
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;border-radius:2rem;background:#0f1117;color:#e2e8f0;border:1px solid #1e293b;text-align:center">
|
| 488 |
+
<div style="width:56px;height:56px;border-radius:1rem;background:linear-gradient(135deg, #00E5CC, #8b5cf6);margin:0 auto 20px auto;color:#ffffff;font-size:24px;font-weight:bold;line-height:56px">FS</div>
|
| 489 |
+
<h2 style="color:#00E5CC;margin-bottom:12px">Welcome to FakeShield!</h2>
|
| 490 |
+
<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin-bottom:24px">
|
| 491 |
+
Hey <strong>{fullName}</strong>, thank you for joining the FakeShield Forensic Suite via {provider}. Your account associated with <strong>{email}</strong> is now active.
|
| 492 |
+
</p>
|
| 493 |
+
<div style="background:#1e2130;border-radius:1rem;padding:20px;text-align:left;font-size:13px;color:#cbd5e1">
|
| 494 |
+
<div style="margin-bottom:8px"><strong>🛡️ Forensic Tools Available:</strong></div>
|
| 495 |
+
<ul style="margin:0;padding-left:16px;color:#94a3b8;line-height:1.5">
|
| 496 |
+
<li>Deepfake Video & Audio Detection</li>
|
| 497 |
+
<li>Metadata & Forensic Lens analysis</li>
|
| 498 |
+
<li>Detailed PDF Reports generation</li>
|
| 499 |
+
</ul>
|
| 500 |
+
</div>
|
| 501 |
+
<p style="margin-top:24px;color:#64748b;font-size:11px">If you did not create this account, please contact support immediately.</p>
|
| 502 |
+
</div>
|
| 503 |
+
"""
|
| 504 |
+
asyncio.create_task(send_email(email, "Welcome to FakeShield!", welcome_body))
|
| 505 |
else:
|
| 506 |
# Update profile info if changed
|
| 507 |
update_data = {"auth_provider": provider}
|
|
|
|
| 513 |
|
| 514 |
await users_collection.update_one({"_id": db_user["_id"]}, {"$set": update_data})
|
| 515 |
db_user.update(update_data)
|
| 516 |
+
|
| 517 |
+
# Send Successful OAuth Login Alert
|
| 518 |
+
login_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 519 |
+
login_body = f"""
|
| 520 |
+
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;border-radius:2rem;background:#0f1117;color:#e2e8f0;border:1px solid #1e293b">
|
| 521 |
+
<div style="width:56px;height:56px;border-radius:1rem;background:linear-gradient(135deg, #00E5CC, #8b5cf6);margin:0 auto 20px auto;color:#ffffff;font-size:24px;font-weight:bold;line-height:56px;text-align:center">FS</div>
|
| 522 |
+
<h2 style="color:#00E5CC;margin-bottom:12px;text-align:center">Successful Login Security Alert</h2>
|
| 523 |
+
<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin-bottom:24px;text-align:center">
|
| 524 |
+
We detected a successful login to your FakeShield account (<strong>{email}</strong>).
|
| 525 |
+
</p>
|
| 526 |
+
<div style="background:#1e2130;border-radius:1rem;padding:20px;font-size:13px;color:#cbd5e1">
|
| 527 |
+
<div style="margin-bottom:6px"><strong>Log Event Details:</strong></div>
|
| 528 |
+
<div style="color:#94a3b8;margin-bottom:4px"><strong>Time:</strong> {login_time} UTC</div>
|
| 529 |
+
<div style="color:#94a3b8"><strong>Method:</strong> OAuth via {provider}</div>
|
| 530 |
+
</div>
|
| 531 |
+
<p style="margin-top:24px;color:#64748b;font-size:11px;text-align:center">If this was you, no action is required. If you do not recognize this login, please contact security.</p>
|
| 532 |
+
</div>
|
| 533 |
+
"""
|
| 534 |
+
asyncio.create_task(send_email(email, "FakeShield Security Alert: Login Detected", login_body))
|
| 535 |
+
|
| 536 |
except Exception as e:
|
| 537 |
print(f"[AUTH] DB offline during OAuth: {e}. Issuing offline session.", flush=True)
|
| 538 |
# DB offline — issue an offline JWT session
|
| 539 |
tier = get_subscription_tier(email)
|
| 540 |
+
access_token = create_access_token(data={"sub": email}, remember_me=remember_me)
|
| 541 |
return {
|
| 542 |
"access_token": access_token,
|
| 543 |
"token_type": "bearer",
|
|
|
|
| 545 |
"name": name or email.split("@")[0].title(),
|
| 546 |
"email": email,
|
| 547 |
"profile_pic": profile_pic,
|
| 548 |
+
"subscription_tier": "free"
|
| 549 |
}
|
| 550 |
}
|
| 551 |
|
| 552 |
+
access_token = create_access_token(data={"sub": db_user["email"]}, remember_me=remember_me)
|
| 553 |
return {
|
| 554 |
"access_token": access_token,
|
| 555 |
"token_type": "bearer",
|
|
|
|
| 561 |
}
|
| 562 |
}
|
| 563 |
|
| 564 |
+
RAZORPAY_KEY_ID = os.getenv("RAZORPAY_KEY_ID", "")
|
| 565 |
+
RAZORPAY_KEY_SECRET = os.getenv("RAZORPAY_KEY_SECRET", "")
|
| 566 |
+
PRO_PLAN_AMOUNT_PAISE = int(os.getenv("PRO_PLAN_AMOUNT_PAISE", "99900"))
|
| 567 |
+
PRO_PLAN_CURRENCY = os.getenv("PRO_PLAN_CURRENCY", "INR")
|
| 568 |
+
PRO_PLAN_NAME = os.getenv("PRO_PLAN_NAME", "FakeShield Pro Shield")
|
| 569 |
+
PRO_PLAN_DURATION_DAYS = int(os.getenv("PRO_PLAN_DURATION_DAYS", "120"))
|
| 570 |
+
|
| 571 |
+
def _require_razorpay_config():
|
| 572 |
+
if not RAZORPAY_KEY_ID or not RAZORPAY_KEY_SECRET:
|
| 573 |
+
raise HTTPException(
|
| 574 |
+
status_code=503,
|
| 575 |
+
detail="Razorpay is not configured. Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET on the backend."
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
@router.get("/payments/razorpay/config")
|
| 579 |
+
async def get_razorpay_payment_config():
|
| 580 |
+
"""Return public payment display config. Does not expose Razorpay secret."""
|
| 581 |
+
return {
|
| 582 |
+
"amount": PRO_PLAN_AMOUNT_PAISE,
|
| 583 |
+
"currency": PRO_PLAN_CURRENCY,
|
| 584 |
+
"name": PRO_PLAN_NAME,
|
| 585 |
+
"duration_days": PRO_PLAN_DURATION_DAYS,
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
@router.post("/payments/razorpay/order")
|
| 589 |
+
async def create_razorpay_order(current_user: dict = Depends(get_current_user)):
|
| 590 |
+
"""Create a Razorpay order for the authenticated user's Pro upgrade."""
|
| 591 |
+
_require_razorpay_config()
|
| 592 |
+
|
| 593 |
+
email = current_user.get("email")
|
| 594 |
+
if not email:
|
| 595 |
+
raise HTTPException(status_code=401, detail="Authenticated user email missing")
|
| 596 |
+
if current_user.get("is_offline"):
|
| 597 |
+
raise HTTPException(
|
| 598 |
+
status_code=503,
|
| 599 |
+
detail="Payment cannot be started while the account database is offline. Please try again when the server is connected."
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
receipt = f"fs_pro_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
|
| 603 |
+
payload = {
|
| 604 |
+
"amount": PRO_PLAN_AMOUNT_PAISE,
|
| 605 |
+
"currency": PRO_PLAN_CURRENCY,
|
| 606 |
+
"receipt": receipt,
|
| 607 |
+
"notes": {
|
| 608 |
+
"email": email,
|
| 609 |
+
"plan": "pro_shield_4_months",
|
| 610 |
+
"duration_days": str(PRO_PLAN_DURATION_DAYS)
|
| 611 |
+
}
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
try:
|
| 615 |
+
async with httpx.AsyncClient(timeout=20) as client:
|
| 616 |
+
response = await client.post(
|
| 617 |
+
"https://api.razorpay.com/v1/orders",
|
| 618 |
+
auth=(RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET),
|
| 619 |
+
json=payload
|
| 620 |
+
)
|
| 621 |
+
except httpx.HTTPError as exc:
|
| 622 |
+
raise HTTPException(status_code=502, detail=f"Could not reach Razorpay: {exc}")
|
| 623 |
+
|
| 624 |
+
if response.status_code >= 400:
|
| 625 |
+
raise HTTPException(status_code=502, detail=f"Razorpay order creation failed: {response.text}")
|
| 626 |
+
|
| 627 |
+
order = response.json()
|
| 628 |
+
return {
|
| 629 |
+
"key_id": RAZORPAY_KEY_ID,
|
| 630 |
+
"order_id": order["id"],
|
| 631 |
+
"amount": order["amount"],
|
| 632 |
+
"currency": order["currency"],
|
| 633 |
+
"name": "FakeShield Forensics",
|
| 634 |
+
"description": PRO_PLAN_NAME,
|
| 635 |
+
"receipt": order.get("receipt", receipt),
|
| 636 |
+
"prefill": {
|
| 637 |
+
"name": current_user.get("fullName") or current_user.get("name") or "",
|
| 638 |
+
"email": email,
|
| 639 |
+
}
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
@router.post("/payments/razorpay/verify")
|
| 643 |
+
async def verify_razorpay_payment(
|
| 644 |
+
req: RazorpayVerifyRequest,
|
| 645 |
+
current_user: dict = Depends(get_current_user)
|
| 646 |
+
):
|
| 647 |
+
"""Verify Razorpay Checkout signature, then activate Pro for the current user."""
|
| 648 |
+
_require_razorpay_config()
|
| 649 |
+
if current_user.get("is_offline"):
|
| 650 |
+
raise HTTPException(
|
| 651 |
+
status_code=503,
|
| 652 |
+
detail="Payment cannot be verified while the account database is offline. Please try again when the server is connected."
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
signed_payload = f"{req.razorpay_order_id}|{req.razorpay_payment_id}".encode("utf-8")
|
| 656 |
+
generated_signature = hmac.new(
|
| 657 |
+
RAZORPAY_KEY_SECRET.encode("utf-8"),
|
| 658 |
+
signed_payload,
|
| 659 |
+
hashlib.sha256
|
| 660 |
+
).hexdigest()
|
| 661 |
+
|
| 662 |
+
if not hmac.compare_digest(generated_signature, req.razorpay_signature):
|
| 663 |
+
raise HTTPException(status_code=400, detail="Invalid Razorpay payment signature")
|
| 664 |
+
|
| 665 |
+
try:
|
| 666 |
+
async with httpx.AsyncClient(timeout=20) as client:
|
| 667 |
+
response = await client.get(
|
| 668 |
+
f"https://api.razorpay.com/v1/payments/{req.razorpay_payment_id}",
|
| 669 |
+
auth=(RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET)
|
| 670 |
+
)
|
| 671 |
+
except httpx.HTTPError as exc:
|
| 672 |
+
raise HTTPException(status_code=502, detail=f"Could not verify payment status with Razorpay: {exc}")
|
| 673 |
+
|
| 674 |
+
if response.status_code >= 400:
|
| 675 |
+
raise HTTPException(status_code=502, detail=f"Razorpay payment lookup failed: {response.text}")
|
| 676 |
+
|
| 677 |
+
payment = response.json()
|
| 678 |
+
if payment.get("order_id") != req.razorpay_order_id:
|
| 679 |
+
raise HTTPException(status_code=400, detail="Payment does not belong to this order")
|
| 680 |
+
|
| 681 |
+
if payment.get("status") != "captured":
|
| 682 |
+
raise HTTPException(status_code=400, detail=f"Payment is not captured yet. Current status: {payment.get('status')}")
|
| 683 |
+
|
| 684 |
+
if int(payment.get("amount", 0)) != PRO_PLAN_AMOUNT_PAISE:
|
| 685 |
+
raise HTTPException(status_code=400, detail="Payment amount mismatch")
|
| 686 |
+
|
| 687 |
+
if payment.get("currency") != PRO_PLAN_CURRENCY:
|
| 688 |
+
raise HTTPException(status_code=400, detail="Payment currency mismatch")
|
| 689 |
+
|
| 690 |
+
email = current_user.get("email")
|
| 691 |
+
activated_at = datetime.utcnow()
|
| 692 |
+
expires_at = activated_at + timedelta(days=PRO_PLAN_DURATION_DAYS)
|
| 693 |
result = await users_collection.update_one(
|
| 694 |
{"email": email},
|
| 695 |
+
{"$set": {
|
| 696 |
+
"subscription_tier": "paid",
|
| 697 |
+
"razorpay_payment_id": req.razorpay_payment_id,
|
| 698 |
+
"razorpay_order_id": req.razorpay_order_id,
|
| 699 |
+
"subscription_updated_at": activated_at,
|
| 700 |
+
"subscription_expires_at": expires_at,
|
| 701 |
+
}}
|
| 702 |
)
|
| 703 |
if result.modified_count == 0:
|
| 704 |
raise HTTPException(status_code=404, detail="User not found")
|
| 705 |
+
|
| 706 |
+
updated_user = public_user_payload({
|
| 707 |
+
**current_user,
|
| 708 |
+
"subscription_tier": "paid",
|
| 709 |
+
"subscription_expires_at": expires_at,
|
| 710 |
+
})
|
| 711 |
+
return {
|
| 712 |
+
"message": "Payment verified and subscription upgraded successfully",
|
| 713 |
+
"payment_id": req.razorpay_payment_id,
|
| 714 |
+
"order_id": req.razorpay_order_id,
|
| 715 |
+
"user": updated_user
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
@router.post("/upgrade")
|
| 719 |
+
async def upgrade_subscription():
|
| 720 |
+
"""Deprecated: payment upgrades must go through Razorpay verification."""
|
| 721 |
+
raise HTTPException(
|
| 722 |
+
status_code=410,
|
| 723 |
+
detail="Manual upgrades are disabled. Use /payments/razorpay/order and /payments/razorpay/verify."
|
| 724 |
+
)
|
| 725 |
|
| 726 |
@router.get("/me")
|
| 727 |
async def get_me(user: dict = Depends(get_current_user)):
|
| 728 |
+
return public_user_payload(user)
|
| 729 |
+
|
| 730 |
+
# ── Update Profile ─────────────────────────────────────────────────────────
|
| 731 |
+
@router.put("/profile")
|
| 732 |
+
async def update_profile(req: UpdateProfileRequest, current_user: dict = Depends(get_current_user)):
|
| 733 |
+
"""
|
| 734 |
+
Update the authenticated user's name and/or email.
|
| 735 |
+
If email changes, it checks for conflicts with existing accounts first.
|
| 736 |
+
Issues a fresh JWT on success so the session reflects any email change.
|
| 737 |
+
"""
|
| 738 |
+
update_data = {}
|
| 739 |
+
|
| 740 |
+
if req.fullName and req.fullName.strip():
|
| 741 |
+
update_data["fullName"] = req.fullName.strip()
|
| 742 |
+
|
| 743 |
+
if req.email and req.email != current_user.get("email"):
|
| 744 |
+
# Check if new email is already taken by another account
|
| 745 |
+
try:
|
| 746 |
+
existing = await users_collection.find_one({"email": req.email})
|
| 747 |
+
if existing:
|
| 748 |
+
raise HTTPException(status_code=400, detail="Email is already in use by another account.")
|
| 749 |
+
except HTTPException:
|
| 750 |
+
raise
|
| 751 |
+
except Exception:
|
| 752 |
+
raise HTTPException(status_code=503, detail="Database unavailable. Try again later.")
|
| 753 |
+
update_data["email"] = req.email
|
| 754 |
+
|
| 755 |
+
if not update_data:
|
| 756 |
+
raise HTTPException(status_code=400, detail="No changes provided.")
|
| 757 |
+
|
| 758 |
+
try:
|
| 759 |
+
await users_collection.update_one(
|
| 760 |
+
{"email": current_user["email"]},
|
| 761 |
+
{"$set": update_data}
|
| 762 |
+
)
|
| 763 |
+
except Exception:
|
| 764 |
+
raise HTTPException(status_code=503, detail="Database unavailable. Try again later.")
|
| 765 |
+
|
| 766 |
+
# Determine the final email (may have changed) for the new token
|
| 767 |
+
new_email = update_data.get("email", current_user["email"])
|
| 768 |
+
new_name = update_data.get("fullName", current_user.get("fullName", ""))
|
| 769 |
+
|
| 770 |
+
# Issue a fresh token so the session reflects the new email if changed
|
| 771 |
+
access_token = create_access_token(data={"sub": new_email})
|
| 772 |
+
|
| 773 |
return {
|
| 774 |
+
"message": "Profile updated successfully.",
|
| 775 |
+
"access_token": access_token,
|
| 776 |
+
"token_type": "bearer",
|
| 777 |
+
"user": {
|
| 778 |
+
"name": new_name,
|
| 779 |
+
"email": new_email,
|
| 780 |
+
"subscription_tier": current_user.get("subscription_tier", "free"),
|
| 781 |
+
"profile_pic": current_user.get("profile_pic"),
|
| 782 |
+
}
|
| 783 |
}
|
| 784 |
|
| 785 |
+
# ── Change Password (OTP-protected) ───────────────────────────────────────
|
| 786 |
+
@router.post("/change-password/request-otp")
|
| 787 |
+
async def change_password_request_otp(
|
| 788 |
+
current_user: dict = Depends(get_current_user)
|
| 789 |
+
):
|
| 790 |
+
"""
|
| 791 |
+
Step 1: Verify the current password, then send a 6-digit OTP to the
|
| 792 |
+
user's registered email address so they can confirm the change.
|
| 793 |
+
"""
|
| 794 |
+
# Block offline / guest sessions — they have no real DB record
|
| 795 |
+
if current_user.get("is_offline") or not current_user.get("_id"):
|
| 796 |
+
raise HTTPException(
|
| 797 |
+
status_code=400,
|
| 798 |
+
detail="Password change is only available for registered accounts. Please log in with your credentials."
|
| 799 |
+
)
|
| 800 |
+
|
| 801 |
+
# Only local-auth accounts have a password to change
|
| 802 |
+
if current_user.get("auth_provider", "local") != "local":
|
| 803 |
+
raise HTTPException(
|
| 804 |
+
status_code=400,
|
| 805 |
+
detail="Password change is not available for OAuth accounts (Google/GitHub)."
|
| 806 |
+
)
|
| 807 |
+
|
| 808 |
+
# Generate and store a 6-digit OTP
|
| 809 |
+
otp = str(secrets.randbelow(900000) + 100000)
|
| 810 |
+
otp_hash = pwd_context.hash(otp)
|
| 811 |
+
otp_expiry = datetime.utcnow() + timedelta(minutes=OTP_EXPIRE_MINUTES)
|
| 812 |
+
|
| 813 |
+
try:
|
| 814 |
+
await users_collection.update_one(
|
| 815 |
+
{"email": current_user["email"]},
|
| 816 |
+
{"$set": {"change_pwd_otp": otp_hash, "change_pwd_otp_expiry": otp_expiry}}
|
| 817 |
+
)
|
| 818 |
+
except Exception:
|
| 819 |
+
raise HTTPException(status_code=503, detail="Database unavailable. Try again later.")
|
| 820 |
+
|
| 821 |
+
# Send OTP email
|
| 822 |
+
html_body = f"""
|
| 823 |
+
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;border-radius:12px;background:#0f1117;color:#e2e8f0;border:1px solid #1e293b">
|
| 824 |
+
<div style="width:56px;height:56px;border-radius:1rem;background:linear-gradient(135deg,#00E5CC,#8b5cf6);margin:0 auto 20px auto;color:#fff;font-size:24px;font-weight:bold;line-height:56px;text-align:center">FS</div>
|
| 825 |
+
<h2 style="color:#00E5CC;margin-bottom:8px;text-align:center">Password Change Request</h2>
|
| 826 |
+
<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin-bottom:24px;text-align:center">
|
| 827 |
+
We received a request to change the password for your FakeShield account
|
| 828 |
+
(<strong>{current_user['email']}</strong>).<br/>Use the code below to confirm. It expires in <strong>{OTP_EXPIRE_MINUTES} minutes</strong>.
|
| 829 |
+
</p>
|
| 830 |
+
<div style="background:#1e2130;border-radius:12px;padding:24px;text-align:center">
|
| 831 |
+
<span style="font-size:40px;font-weight:bold;letter-spacing:12px;color:#00E5CC">{otp}</span>
|
| 832 |
+
</div>
|
| 833 |
+
<p style="margin-top:24px;color:#64748b;font-size:12px;text-align:center">
|
| 834 |
+
If you did not request this password change, your account may be compromised.
|
| 835 |
+
Please contact support immediately.
|
| 836 |
+
</p>
|
| 837 |
+
</div>
|
| 838 |
+
"""
|
| 839 |
+
asyncio.create_task(
|
| 840 |
+
send_email(current_user["email"], "FakeShield: Confirm Your Password Change", html_body)
|
| 841 |
+
)
|
| 842 |
+
|
| 843 |
+
return {"message": "OTP sent to your registered email address."}
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
@router.post("/change-password/confirm")
|
| 847 |
+
async def change_password_confirm(
|
| 848 |
+
req: ChangePasswordConfirmRequest,
|
| 849 |
+
request: Request,
|
| 850 |
+
current_user: dict = Depends(get_current_user)
|
| 851 |
+
):
|
| 852 |
+
"""
|
| 853 |
+
Step 2: Verify OTP + current password, then update the password in the DB.
|
| 854 |
+
"""
|
| 855 |
+
if len(req.new_password) < 8:
|
| 856 |
+
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
| 857 |
+
|
| 858 |
+
# Block offline / guest sessions
|
| 859 |
+
if current_user.get("is_offline") or not current_user.get("_id"):
|
| 860 |
+
raise HTTPException(
|
| 861 |
+
status_code=400,
|
| 862 |
+
detail="Password change is only available for registered accounts."
|
| 863 |
+
)
|
| 864 |
+
|
| 865 |
+
# Fetch fresh user record to check OTP validity
|
| 866 |
+
try:
|
| 867 |
+
db_user = await users_collection.find_one({
|
| 868 |
+
"email": current_user["email"],
|
| 869 |
+
"change_pwd_otp": {"$exists": True},
|
| 870 |
+
"change_pwd_otp_expiry": {"$gt": datetime.utcnow()}
|
| 871 |
+
})
|
| 872 |
+
except Exception:
|
| 873 |
+
raise HTTPException(status_code=503, detail="Database unavailable. Try again later.")
|
| 874 |
+
|
| 875 |
+
if not db_user:
|
| 876 |
+
raise HTTPException(status_code=400, detail="OTP not found or has expired. Please request a new one.")
|
| 877 |
+
|
| 878 |
+
# Verify OTP
|
| 879 |
+
if not pwd_context.verify(req.otp, db_user["change_pwd_otp"]):
|
| 880 |
+
raise HTTPException(status_code=400, detail="Invalid OTP. Please try again.")
|
| 881 |
+
|
| 882 |
+
# Update password and clear OTP fields
|
| 883 |
+
new_hash = get_password_hash(req.new_password)
|
| 884 |
+
try:
|
| 885 |
+
await users_collection.update_one(
|
| 886 |
+
{"_id": db_user["_id"]},
|
| 887 |
+
{
|
| 888 |
+
"$set": {"password": new_hash},
|
| 889 |
+
"$unset": {"change_pwd_otp": "", "change_pwd_otp_expiry": ""}
|
| 890 |
+
}
|
| 891 |
+
)
|
| 892 |
+
except Exception:
|
| 893 |
+
raise HTTPException(status_code=503, detail="Database unavailable. Try again later.")
|
| 894 |
+
|
| 895 |
+
# Send confirmation email
|
| 896 |
+
confirm_body = f"""
|
| 897 |
+
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;border-radius:12px;background:#0f1117;color:#e2e8f0;border:1px solid #1e293b">
|
| 898 |
+
<div style="width:56px;height:56px;border-radius:1rem;background:linear-gradient(135deg,#00E5CC,#8b5cf6);margin:0 auto 20px auto;color:#fff;font-size:24px;font-weight:bold;line-height:56px;text-align:center">FS</div>
|
| 899 |
+
<h2 style="color:#00E5CC;margin-bottom:8px;text-align:center">✅ Password Changed Successfully</h2>
|
| 900 |
+
<p style="color:#94a3b8;font-size:14px;line-height:1.6;text-align:center">
|
| 901 |
+
The password for your FakeShield account (<strong>{db_user['email']}</strong>) was changed on
|
| 902 |
+
<strong>{datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC</strong>.
|
| 903 |
+
</p>
|
| 904 |
+
<p style="margin-top:24px;color:#64748b;font-size:12px;text-align:center">
|
| 905 |
+
If you did not perform this action, please contact support immediately and reset your password.
|
| 906 |
+
</p>
|
| 907 |
+
</div>
|
| 908 |
+
"""
|
| 909 |
+
asyncio.create_task(
|
| 910 |
+
send_email(db_user["email"], "FakeShield: Your Password Has Been Changed", confirm_body)
|
| 911 |
+
)
|
| 912 |
+
|
| 913 |
+
await log_user_activity(db_user["email"], "Password Changed", request)
|
| 914 |
+
|
| 915 |
+
return {"message": "Password changed successfully."}
|
| 916 |
+
|
| 917 |
+
|
| 918 |
@router.get("/test")
|
| 919 |
async def auth_test():
|
| 920 |
return {
|
| 921 |
"message": "Auth router is reachable!",
|
| 922 |
+
"version": "production-oauth-v4",
|
| 923 |
"handshake_type": "raw_dict"
|
| 924 |
}
|
| 925 |
+
|
| 926 |
+
@router.get("/activity")
|
| 927 |
+
async def get_activity_log(current_user: dict = Depends(get_current_user)):
|
| 928 |
+
"""
|
| 929 |
+
Fetch the recent activity (logins, password changes) for the user.
|
| 930 |
+
"""
|
| 931 |
+
if current_user.get("is_offline"):
|
| 932 |
+
# Mock data for offline access
|
| 933 |
+
return [
|
| 934 |
+
{
|
| 935 |
+
"id": 1, "event": "Success Login (Offline Mode)",
|
| 936 |
+
"ip": "127.0.0.1", "device": "Current Device",
|
| 937 |
+
"location": "Local", "time": "Just now"
|
| 938 |
+
}
|
| 939 |
+
]
|
| 940 |
+
|
| 941 |
+
try:
|
| 942 |
+
cursor = activity_logs_collection.find({"email": current_user["email"]}).sort("timestamp", -1).limit(50)
|
| 943 |
+
logs = await cursor.to_list(length=50)
|
| 944 |
+
|
| 945 |
+
results = []
|
| 946 |
+
for i, log in enumerate(logs):
|
| 947 |
+
dt = log.get("timestamp", datetime.utcnow())
|
| 948 |
+
# Basic relative time formatter
|
| 949 |
+
delta = datetime.utcnow() - dt
|
| 950 |
+
seconds = delta.total_seconds()
|
| 951 |
+
if seconds < 60:
|
| 952 |
+
time_str = "Just now"
|
| 953 |
+
elif seconds < 3600:
|
| 954 |
+
time_str = f"{int(seconds//60)} mins ago"
|
| 955 |
+
elif seconds < 86400:
|
| 956 |
+
time_str = f"{int(seconds//3600)} hours ago"
|
| 957 |
+
else:
|
| 958 |
+
time_str = f"{int(seconds//86400)} days ago"
|
| 959 |
+
|
| 960 |
+
results.append({
|
| 961 |
+
"id": str(log.get("_id", i)),
|
| 962 |
+
"event": log.get("event"),
|
| 963 |
+
"ip": log.get("ip"),
|
| 964 |
+
"device": log.get("device"),
|
| 965 |
+
"location": log.get("location"),
|
| 966 |
+
"time": time_str
|
| 967 |
+
})
|
| 968 |
+
return results
|
| 969 |
+
except Exception as e:
|
| 970 |
+
return []
|
| 971 |
+
|
| 972 |
+
def serialize_mongo_doc(doc):
|
| 973 |
+
doc["_id"] = str(doc["_id"])
|
| 974 |
+
return doc
|
| 975 |
+
|
| 976 |
+
@router.get("/export-data")
|
| 977 |
+
async def export_data(current_user: dict = Depends(get_current_user)):
|
| 978 |
+
"""
|
| 979 |
+
Exports all data associated with the user across all forensic collections and activity logs.
|
| 980 |
+
"""
|
| 981 |
+
email = current_user.get("email")
|
| 982 |
+
if not email or current_user.get("is_offline"):
|
| 983 |
+
raise HTTPException(status_code=400, detail="Cannot export data in offline/guest mode.")
|
| 984 |
+
|
| 985 |
+
try:
|
| 986 |
+
activity = [serialize_mongo_doc(d) async for d in activity_logs_collection.find({"email": email})]
|
| 987 |
+
video = [serialize_mongo_doc(d) async for d in video_results_collection.find({"user_email": email})]
|
| 988 |
+
audio = [serialize_mongo_doc(d) async for d in audio_results_collection.find({"user_email": email})]
|
| 989 |
+
image = [serialize_mongo_doc(d) async for d in image_results_collection.find({"user_email": email})]
|
| 990 |
+
text = [serialize_mongo_doc(d) async for d in text_results_collection.find({"user_email": email})]
|
| 991 |
+
|
| 992 |
+
return {
|
| 993 |
+
"email": email,
|
| 994 |
+
"export_time": datetime.utcnow().isoformat() + "Z",
|
| 995 |
+
"activity_logs": activity,
|
| 996 |
+
"detections": {
|
| 997 |
+
"video": video,
|
| 998 |
+
"audio": audio,
|
| 999 |
+
"image": image,
|
| 1000 |
+
"text": text
|
| 1001 |
+
}
|
| 1002 |
+
}
|
| 1003 |
+
except Exception as e:
|
| 1004 |
+
print(f"[EXPORT ERROR] {e}")
|
| 1005 |
+
raise HTTPException(status_code=500, detail="An error occurred while exporting data.")
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
@router.get("/preferences")
|
| 1009 |
+
async def get_preferences(current_user: dict = Depends(get_current_user)):
|
| 1010 |
+
if current_user.get("is_offline"):
|
| 1011 |
+
return {"email_scan_complete": False, "email_suspicious_login": False, "email_monthly_report": False}
|
| 1012 |
+
|
| 1013 |
+
db_user = await users_collection.find_one({"email": current_user["email"]})
|
| 1014 |
+
if not db_user:
|
| 1015 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 1016 |
+
|
| 1017 |
+
prefs = db_user.get("preferences", {
|
| 1018 |
+
"email_scan_complete": True,
|
| 1019 |
+
"email_suspicious_login": True,
|
| 1020 |
+
"email_monthly_report": False
|
| 1021 |
+
})
|
| 1022 |
+
return prefs
|
| 1023 |
+
|
| 1024 |
+
@router.put("/preferences")
|
| 1025 |
+
async def update_preferences(req: UpdatePreferencesRequest, current_user: dict = Depends(get_current_user)):
|
| 1026 |
+
if current_user.get("is_offline"):
|
| 1027 |
+
raise HTTPException(status_code=400, detail="Cannot update preferences in offline/guest mode.")
|
| 1028 |
+
|
| 1029 |
+
await users_collection.update_one(
|
| 1030 |
+
{"email": current_user["email"]},
|
| 1031 |
+
{"$set": {"preferences": req.model_dump()}}
|
| 1032 |
+
)
|
| 1033 |
+
return {"message": "Preferences updated successfully"}
|
| 1034 |
+
|
| 1035 |
+
@router.delete("/account")
|
| 1036 |
+
async def delete_account(current_user: dict = Depends(get_current_user)):
|
| 1037 |
+
email = current_user.get("email")
|
| 1038 |
+
if not email or current_user.get("is_offline"):
|
| 1039 |
+
raise HTTPException(status_code=400, detail="Cannot delete account in offline/guest mode.")
|
| 1040 |
+
|
| 1041 |
+
try:
|
| 1042 |
+
# Purge all forensic data and logs
|
| 1043 |
+
await activity_logs_collection.delete_many({"email": email})
|
| 1044 |
+
await video_results_collection.delete_many({"user_email": email})
|
| 1045 |
+
await audio_results_collection.delete_many({"user_email": email})
|
| 1046 |
+
await image_results_collection.delete_many({"user_email": email})
|
| 1047 |
+
await text_results_collection.delete_many({"user_email": email})
|
| 1048 |
+
|
| 1049 |
+
# Purge account
|
| 1050 |
+
await users_collection.delete_one({"email": email})
|
| 1051 |
+
|
| 1052 |
+
return {"message": "Account and all associated forensic data deleted successfully."}
|
| 1053 |
+
except Exception as e:
|
| 1054 |
+
print(f"[AUTH DELETE ERROR] {e}")
|
| 1055 |
+
raise HTTPException(status_code=500, detail="An error occurred while deleting account records.")
|
backend/app/routers/contact_router.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import html
|
| 3 |
+
import os
|
| 4 |
+
import smtplib
|
| 5 |
+
from email.message import EmailMessage
|
| 6 |
+
from email.utils import formataddr
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException, status
|
| 9 |
+
from pydantic import BaseModel, EmailStr, Field, field_validator
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/v1/contact", tags=["Contact"])
|
| 12 |
+
|
| 13 |
+
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
| 14 |
+
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
| 15 |
+
SMTP_USER = os.getenv("SMTP_USER", "")
|
| 16 |
+
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
| 17 |
+
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
|
| 18 |
+
CONTACT_EMAIL_TO = os.getenv("CONTACT_EMAIL_TO", "virdiakash77@gmail.com")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ContactRequest(BaseModel):
|
| 22 |
+
name: str = Field(min_length=2, max_length=100)
|
| 23 |
+
email: EmailStr
|
| 24 |
+
phone: str = Field(default="", max_length=30)
|
| 25 |
+
message: str = Field(min_length=10, max_length=5000)
|
| 26 |
+
|
| 27 |
+
@field_validator("name", "phone", "message")
|
| 28 |
+
@classmethod
|
| 29 |
+
def strip_values(cls, value: str) -> str:
|
| 30 |
+
return value.strip()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _deliver_contact_email(contact: ContactRequest) -> None:
|
| 34 |
+
if not SMTP_USER or not SMTP_PASSWORD:
|
| 35 |
+
raise RuntimeError("SMTP credentials are not configured")
|
| 36 |
+
|
| 37 |
+
safe_name = html.escape(contact.name)
|
| 38 |
+
safe_email = html.escape(str(contact.email))
|
| 39 |
+
safe_phone = html.escape(contact.phone or "Not provided")
|
| 40 |
+
safe_message = html.escape(contact.message).replace("\n", "<br>")
|
| 41 |
+
|
| 42 |
+
email = EmailMessage()
|
| 43 |
+
email["Subject"] = f"FakeShield contact request from {contact.name}"
|
| 44 |
+
email["From"] = formataddr(("FakeShield Website", SMTP_FROM or SMTP_USER))
|
| 45 |
+
email["To"] = CONTACT_EMAIL_TO
|
| 46 |
+
email["Reply-To"] = str(contact.email)
|
| 47 |
+
email.set_content(
|
| 48 |
+
f"Name: {contact.name}\nEmail: {contact.email}\n"
|
| 49 |
+
f"Phone: {contact.phone or 'Not provided'}\n\nMessage:\n{contact.message}"
|
| 50 |
+
)
|
| 51 |
+
email.add_alternative(
|
| 52 |
+
f"""
|
| 53 |
+
<div style="font-family:Arial,sans-serif;max-width:640px;margin:auto;padding:28px;background:#0f172a;color:#e2e8f0;border-radius:16px">
|
| 54 |
+
<h2 style="color:#00e5cc">New FakeShield contact request</h2>
|
| 55 |
+
<p><strong>Name:</strong> {safe_name}</p>
|
| 56 |
+
<p><strong>Email:</strong> <a style="color:#00e5cc" href="mailto:{safe_email}">{safe_email}</a></p>
|
| 57 |
+
<p><strong>Phone:</strong> {safe_phone}</p>
|
| 58 |
+
<div style="margin-top:20px;padding:18px;background:#1e293b;border-radius:12px;line-height:1.6">{safe_message}</div>
|
| 59 |
+
</div>
|
| 60 |
+
""",
|
| 61 |
+
subtype="html",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as smtp:
|
| 65 |
+
smtp.ehlo()
|
| 66 |
+
smtp.starttls()
|
| 67 |
+
smtp.ehlo()
|
| 68 |
+
smtp.login(SMTP_USER, SMTP_PASSWORD)
|
| 69 |
+
smtp.send_message(email)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@router.post("", status_code=status.HTTP_200_OK)
|
| 73 |
+
async def submit_contact(contact: ContactRequest):
|
| 74 |
+
try:
|
| 75 |
+
await asyncio.to_thread(_deliver_contact_email, contact)
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
print(f"[CONTACT MAIL ERROR] {exc}", flush=True)
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 80 |
+
detail="We could not send your message right now. Please try again later.",
|
| 81 |
+
) from exc
|
| 82 |
+
|
| 83 |
+
return {"message": "Your message was sent successfully."}
|
backend/app/routers/dashboard_router.py
CHANGED
|
@@ -14,6 +14,7 @@ from app.database import (
|
|
| 14 |
audio_results_collection,
|
| 15 |
video_results_collection,
|
| 16 |
)
|
|
|
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
|
| 19 |
|
|
@@ -73,6 +74,12 @@ async def save_scan_internal(
|
|
| 73 |
print(f"[DB] Invalid lab: {lab}")
|
| 74 |
return
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
doc = {
|
| 77 |
"user_email": email,
|
| 78 |
"lab": lab,
|
|
@@ -218,6 +225,16 @@ async def get_scan_details(scan_id: str, user: dict = Depends(get_current_user))
|
|
| 218 |
for lab, col in collections:
|
| 219 |
doc = await col.find_one({"scan_id": scan_id, "user_email": email})
|
| 220 |
if doc:
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
raise HTTPException(404, "Scan not found or not authorized")
|
|
|
|
| 14 |
audio_results_collection,
|
| 15 |
video_results_collection,
|
| 16 |
)
|
| 17 |
+
from app.models.suggestion_engine import generate_text_improvement_suggestions
|
| 18 |
|
| 19 |
router = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
|
| 20 |
|
|
|
|
| 74 |
print(f"[DB] Invalid lab: {lab}")
|
| 75 |
return
|
| 76 |
|
| 77 |
+
if full_result and "improvement_suggestions" not in full_result:
|
| 78 |
+
full_result["improvement_suggestions"] = generate_text_improvement_suggestions(
|
| 79 |
+
full_result,
|
| 80 |
+
text=full_result.get("text_preview", "")
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
doc = {
|
| 84 |
"user_email": email,
|
| 85 |
"lab": lab,
|
|
|
|
| 225 |
for lab, col in collections:
|
| 226 |
doc = await col.find_one({"scan_id": scan_id, "user_email": email})
|
| 227 |
if doc:
|
| 228 |
+
doc = _serialize(doc)
|
| 229 |
+
full_result = doc.get("full_result")
|
| 230 |
+
|
| 231 |
+
if isinstance(full_result, dict) and "improvement_suggestions" not in full_result:
|
| 232 |
+
full_result["improvement_suggestions"] = generate_text_improvement_suggestions(
|
| 233 |
+
full_result,
|
| 234 |
+
text=full_result.get("text_preview", "")
|
| 235 |
+
)
|
| 236 |
+
doc["full_result"] = full_result
|
| 237 |
+
|
| 238 |
+
return {"status": "success", "lab": lab, "data": doc}
|
| 239 |
|
| 240 |
raise HTTPException(404, "Scan not found or not authorized")
|
backend/backend_log.txt
DELETED
|
Binary file (16.5 kB)
|
|
|
backend/download_log.txt
DELETED
|
Binary file (10.4 kB)
|
|
|
backend/error_out.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
{"stage": "BUILD_ERROR", "hardware": {"current": null, "requested": "cpu-basic"}, "gcTimeout": 172800, "errorMessage": "Job failed with exit code: 1. Reason: cache miss: [10/10] COPY . .\ncache miss: [ 9/10] RUN pip install --no-cache-dir \"pyannote.audio>=3.1.0\" \"protobuf~=4.25.3\" python-magic\ncache miss: [ 5/10] RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir fastapi \"uvicorn[standard]\" python-multipart python-dotenv pydantic \"pydantic-settings\" motor \"passlib[bcrypt]\" PyJWT google-generativeai\ncache miss: [ 6/10] RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu\ncache miss: [ 8/10] RUN pip install --no-cache-dir Pillow piexif opencv-python-headless \"soundfile>=0.12.0\" \"librosa>=0.10.0\" \"resampy>=0.4.2\"\ncache miss: [ 7/10] RUN pip install --no-cache-dir transformers accelerate \"sentence-transformers\" scikit-learn numpy scipy\ncache miss: [ 4/10] COPY requirements.txt .\ncache miss: [ 3/10] RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 libsndfile1 ffmpeg libmagic1 && rm -rf /var/lib/apt/lists/*\n{\"total\":15,\"completed\":14,\"user_total\":10,\"user_cached\":1,\"user_completed\":9,\"user_cacheable\":9,\"from\":1,\"miss\":8,\"client_duration_ms\":350984}\n", "replicas": {"requested": 1}, "devMode": false, "domains": [{"domain": "akash4911-fakeshield-api.hf.space", "stage": "READY"}]}
|
|
|
|
|
|