Akash4911 commited on
Commit
33ad6db
·
1 Parent(s): 66b6851

Deploy backend updates

Browse files
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
- # Bypass for Offline Mode / Development
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 Guest access.")
36
  return {
37
- "email": "offline_user@fakeshield.local",
38
- "full_name": "Offline Tester",
39
- "subscription_tier": "paid", # Grant pro for testing
40
  "is_offline": True
41
  }
42
 
43
  async def verify_paid_tier(user: dict = Depends(get_current_user)):
44
- if user.get("subscription_tier") != "paid":
45
  raise HTTPException(
46
  status_code=403,
47
- detail="This feature requires a Pro subscription. Please upgrade to access."
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
- load_dotenv()
 
 
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
- from app.database import users_collection
12
- from app.dependencies import get_current_user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 # 7 days
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
 
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
- paid_emails = ["virdisaab419@gmail.com", "virdiakash77@gmail.com"]
54
- return "paid" if email.lower() in paid_emails else "free"
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": "paid" # Grant full access in offline mode
113
  }
114
  }
115
 
@@ -128,10 +273,32 @@ async def login(user: UserLogin):
128
  except:
129
  pass
130
 
131
- access_token = create_access_token(data={"sub": db_user["email"]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": name or email.split("@")[0].title(),
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": "paid" # Grant access in offline mode
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
- @router.post("/upgrade")
265
- async def upgrade_subscription(email: str):
266
- """Manual upgrade endpoint (to be called after QR payment confirmation)"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  result = await users_collection.update_one(
268
  {"email": email},
269
- {"$set": {"subscription_tier": "paid"}}
 
 
 
 
 
 
270
  )
271
  if result.modified_count == 0:
272
  raise HTTPException(status_code=404, detail="User not found")
273
- return {"message": "Subscription upgraded successfully"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  @router.get("/me")
276
  async def get_me(user: dict = Depends(get_current_user)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  return {
278
- "name": user.get("fullName"),
279
- "email": user.get("email"),
280
- "subscription_tier": user.get("subscription_tier", "free"),
281
- "profile_pic": user.get("profile_pic")
 
 
 
 
 
282
  }
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  @router.get("/test")
285
  async def auth_test():
286
  return {
287
  "message": "Auth router is reachable!",
288
- "version": "production-oauth-v3",
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 &amp; Audio Detection</li>
178
+ <li>Metadata &amp; 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 &amp; 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 &amp; Audio Detection</li>
497
+ <li>Metadata &amp; 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
- return {"status": "success", "lab": lab, "data": _serialize(doc)}
 
 
 
 
 
 
 
 
 
 
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"}]}