File size: 17,959 Bytes
4f48a4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
"""
fastapi_server.py β€” Production-grade FastAPI server for Proofly
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Run with:
    uvicorn fastapi_server:app --host 0.0.0.0 --port 8000 --workers 4

For development (auto-reload):
    uvicorn fastapi_server:app --reload --port 8000

Dependencies (add to requirements.txt):
    fastapi
    uvicorn[standard]
    python-jose[cryptography]
    motor            # Async MongoDB
    passlib[bcrypt]
    python-multipart
    jinja2
    slowapi          # Rate limiting
    python-dotenv
    certifi

Architecture Notes
──────────────────
β€’ Routes mirror Flask app.py 1-for-1 so HTML templates are reused unchanged.
β€’ Motor (async pymongo) replaces the sync pymongo driver.
β€’ JSON Web Tokens issued as HttpOnly cookies via python-jose.
β€’ Rate limiting via slowapi (identical semantics to Flask-Limiter).
β€’ Lifespan event handles DB index creation at startup.
β€’ Static files served by FastAPI StaticFiles mount.
"""

import os
import logging
import re as _re
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Optional

from fastapi import (
    FastAPI, Request, Response, Form, Cookie, UploadFile, File,
    Depends, HTTPException, status
)
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from jose import JWTError, jwt
from passlib.context import CryptContext
from dotenv import load_dotenv

# Optional β€” install slowapi for rate limiting
try:
    from slowapi import Limiter, _rate_limit_exceeded_handler
    from slowapi.util import get_remote_address
    from slowapi.errors import RateLimitExceeded
    limiter = Limiter(key_func=get_remote_address)
    HAS_LIMITER = True
except ImportError:
    limiter = None
    HAS_LIMITER = False
    print("[WARN] slowapi not installed β€” rate limiting disabled. pip install slowapi")

load_dotenv()

# ── Config ─────────────────────────────────────────────────────────────────────
SECRET_KEY          = os.getenv("JWT_SECRET_KEY", "change-this-jwt-secret")
ALGORITHM           = "HS256"
ACCESS_TOKEN_MINS   = int(os.getenv("JWT_ACCESS_TOKEN_MINS", "15"))
REFRESH_TOKEN_DAYS  = int(os.getenv("JWT_REFRESH_TOKEN_DAYS", "7"))
BCRYPT_PEPPER       = os.getenv("BCRYPT_PEPPER", "")
MONGO_URI           = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
MONGO_DB_NAME       = os.getenv("MONGO_DB_NAME", "factcheck")

pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")

# ── Logging ────────────────────────────────────────────────────────────────────
class _PrivacyFilter(logging.Filter):
    _PATTERNS = [
        _re.compile(r'[\w.+-]+@[\w-]+\.[a-z]{2,}', _re.I),
        _re.compile(r'(?i)(password|passwd|secret|token|pepper)\s*[=:]\s*\S+'),
    ]
    def filter(self, record):
        msg = str(record.getMessage())
        for pat in self._PATTERNS:
            msg = pat.sub('[REDACTED]', msg)
        record.msg  = msg
        record.args = ()
        return True

logging.basicConfig(filename='app.log', level=logging.INFO)
_root = logging.getLogger()
_root.addFilter(_PrivacyFilter())

# ── Motor (async MongoDB) ──────────────────────────────────────────────────────
try:
    from motor.motor_asyncio import AsyncIOMotorClient
    import certifi
    _motor_client = AsyncIOMotorClient(
        MONGO_URI,
        serverSelectionTimeoutMS=5000,
        tlsCAFile=certifi.where(),
        tlsAllowInvalidCertificates=True,
    )
    _adb = _motor_client[MONGO_DB_NAME]
    HAS_MOTOR = True
except ImportError:
    _adb = None
    HAS_MOTOR = False
    print("[WARN] motor not installed β€” DB calls will fail. pip install motor")


# ── Lifespan (startup/shutdown) ────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
    """Run DB index creation at startup."""
    if HAS_MOTOR and _adb:
        try:
            from pymongo import ASCENDING, DESCENDING
            await _adb.users.create_index([("email", ASCENDING)], unique=True, name="email_unique")
            await _adb.history.create_index([("user_id", ASCENDING), ("created_at", DESCENDING)], name="user_history_idx")
            await _adb.revoked_tokens.create_index([("exp", ASCENDING)], expireAfterSeconds=0, name="token_ttl")
            await _adb.cached_results.create_index([("normalized_claim", ASCENDING)], unique=True, name="claim_cache_idx")
            logging.info("[DB] MongoDB indexes ensured.")
        except Exception as e:
            logging.warning(f"[DB] Index creation warning: {e}")
    yield
    # Shutdown β€” close motor connection
    if HAS_MOTOR:
        _motor_client.close()


# ── App ────────────────────────────────────────────────────────────────────────
app = FastAPI(title="Proofly API", lifespan=lifespan)

if HAS_LIMITER:
    app.state.limiter = limiter
    app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")


# ── JWT Helpers ────────────────────────────────────────────────────────────────
def _create_token(data: dict, expires_delta: timedelta) -> str:
    payload = data.copy()
    payload["exp"] = datetime.now(timezone.utc) + expires_delta
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def create_access_token(user_id: str, username: str, is_admin: bool) -> str:
    return _create_token(
        {"sub": user_id, "username": username, "is_admin": is_admin},
        timedelta(minutes=ACCESS_TOKEN_MINS)
    )

def create_refresh_token(user_id: str) -> str:
    return _create_token({"sub": user_id, "type": "refresh"}, timedelta(days=REFRESH_TOKEN_DAYS))

def _decode_token(token: str) -> Optional[dict]:
    try:
        return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError:
        return None

def _set_token_cookies(response: Response, access_token: str, refresh_token: str):
    opts = dict(httponly=True, samesite="strict", secure=False)  # secure=True in production
    response.set_cookie("access_token_cookie",  access_token,  max_age=ACCESS_TOKEN_MINS * 60, **opts)
    response.set_cookie("refresh_token_cookie", refresh_token, max_age=REFRESH_TOKEN_DAYS * 86400, **opts)

def _unset_token_cookies(response: Response):
    response.delete_cookie("access_token_cookie")
    response.delete_cookie("refresh_token_cookie")


# ── Current User Dependency ────────────────────────────────────────────────────
class CurrentUser:
    def __init__(self, user_id=None, username=None, is_admin=False):
        self.user_id  = user_id
        self.username = username
        self.is_admin = is_admin

async def get_current_user(
    access_token_cookie: Optional[str] = Cookie(default=None)
) -> CurrentUser:
    if not access_token_cookie:
        return CurrentUser()
    payload = _decode_token(access_token_cookie)
    if not payload:
        return CurrentUser()
    # Check revoked
    if HAS_MOTOR and _adb:
        revoked = await _adb.revoked_tokens.find_one({"jti": payload.get("jti")})
        if revoked:
            return CurrentUser()
    return CurrentUser(
        user_id  = payload.get("sub"),
        username = payload.get("username", "User"),
        is_admin = payload.get("is_admin", False),
    )

def require_auth(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
    if not user.user_id:
        raise HTTPException(status_code=302, headers={"Location": "/login"})
    return user

def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
    if not user.user_id or not user.is_admin:
        raise HTTPException(status_code=403, detail="Admin access required.")
    return user


# ── Password Helpers ───────────────────────────────────────────────────────────
def _pepper(pw: str) -> str: return pw + BCRYPT_PEPPER
def hash_pw(pw: str) -> str: return pwd_ctx.hash(_pepper(pw))
def verify_pw(pw: str, hashed: str) -> bool: return pwd_ctx.verify(_pepper(pw), hashed)


# ── Auth Routes ────────────────────────────────────────────────────────────────
@app.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
    return templates.TemplateResponse("register.html", {"request": request})

@app.post("/register", response_class=HTMLResponse)
async def register(
    request: Request,
    username: str = Form(...),
    email: str = Form(...),
    password: str = Form(...),
    confirm_password: str = Form(...),
):
    errs = []
    if not all([username, email, password]):
        errs.append("All fields are required.")
    if password != confirm_password:
        errs.append("Passwords do not match.")
    if len(password) < 6:
        errs.append("Password must be at least 6 characters.")

    if not errs and HAS_MOTOR:
        existing = await _adb.users.find_one({"email": email.lower()})
        if existing:
            errs.append("An account with that email already exists.")

    if errs:
        return templates.TemplateResponse("register.html", {"request": request, "errors": errs})

    is_admin = (await _adb.users.count_documents({})) == 0 if HAS_MOTOR else False
    pw_hash  = hash_pw(password)
    if HAS_MOTOR:
        await _adb.users.insert_one({
            "username": username, "email": email.lower(),
            "password_hash": pw_hash, "is_admin": is_admin,
            "created_at": datetime.now(timezone.utc)
        })
    return RedirectResponse("/login?registered=1", status_code=303)

@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
    return templates.TemplateResponse("login.html", {"request": request})

@app.post("/login")
async def login(
    request: Request,
    email: str = Form(...),
    password: str = Form(...),
):
    if not HAS_MOTOR:
        return templates.TemplateResponse("login.html", {"request": request, "error": "DB unavailable."})
    user = await _adb.users.find_one({"email": email.lower()})
    if not user or not verify_pw(password, user["password_hash"]):
        return templates.TemplateResponse("login.html", {"request": request, "error": "Invalid credentials."})

    uid  = str(user["_id"])
    at   = create_access_token(uid, user["username"], user.get("is_admin", False))
    rt   = create_refresh_token(uid)

    resp = RedirectResponse("/", status_code=303)
    _set_token_cookies(resp, at, rt)
    return resp

@app.get("/logout")
@app.post("/logout")
async def logout():
    resp = RedirectResponse("/login", status_code=303)
    _unset_token_cookies(resp)
    return resp


# ── Main Routes ────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, user: CurrentUser = Depends(require_auth)):
    return templates.TemplateResponse("index.html", {"request": request, "g": user})

@app.post("/check")
async def check_claim(
    request: Request,
    claim: str = Form(...),
    user: CurrentUser = Depends(require_auth),
):
    from api_wrapper import run_fact_check_api
    claim = claim.strip()
    if not claim:
        return JSONResponse({"success": False, "error": "Claim cannot be empty"}, status_code=400)
    # Cache lookup
    norm = claim.strip().lower()
    cached = None
    if HAS_MOTOR:
        doc = await _adb.cached_results.find_one({"normalized_claim": norm})
        if doc:
            cached = doc.get("result")
    result = cached or run_fact_check_api(claim)
    if result.get("success"):
        if not cached and HAS_MOTOR:
            await _adb.cached_results.update_one(
                {"normalized_claim": norm},
                {"$set": {"result": result, "updated_at": datetime.now(timezone.utc)},
                 "$setOnInsert": {"created_at": datetime.now(timezone.utc)}},
                upsert=True
            )
        if HAS_MOTOR:
            await _adb.history.insert_one({
                "user_id":        user.user_id,
                "claim":          claim,
                "verdict":        result.get("verdict", "Unknown"),
                "confidence":     result.get("confidence", 0.0),
                "evidence_count": result.get("total_evidence", 0),
                "created_at":     datetime.now(timezone.utc),
            })
    return JSONResponse(result)

@app.get("/history", response_class=HTMLResponse)
async def history(request: Request, user: CurrentUser = Depends(require_auth)):
    records = []
    if HAS_MOTOR:
        from pymongo import DESCENDING
        records = await _adb.history.find(
            {"user_id": user.user_id}
        ).sort("created_at", DESCENDING).limit(50).to_list(50)
    return templates.TemplateResponse("history.html", {"request": request, "g": user, "records": records})

@app.get("/results", response_class=HTMLResponse)
async def results(request: Request, user: CurrentUser = Depends(require_auth)):
    # Results are stored in session in Flask; in FastAPI we redirect to / if empty
    return RedirectResponse("/")

@app.post("/ocr")
async def ocr_image(image: UploadFile = File(...), user: CurrentUser = Depends(require_auth)):
    try:
        import easyocr, numpy as np
        from PIL import Image
        import io
        image_bytes = await image.read()
        img    = Image.open(io.BytesIO(image_bytes)).convert('RGB')
        reader = easyocr.Reader(['en'], gpu=False)
        text   = ' '.join([r[1] for r in reader.readtext(np.array(img))]).strip()
        return JSONResponse({"success": True, "text": text})
    except ImportError:
        return JSONResponse({"success": False, "error": "OCR library not installed."}, status_code=500)
    except Exception:
        return JSONResponse({"success": False, "error": "Could not process image."}, status_code=500)


# ── Admin Routes ───────────────────────────────────────────────────────────────
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, user: CurrentUser = Depends(require_admin)):
    from project.database import get_system_stats, get_global_history, list_all_users
    stats   = get_system_stats()
    history = get_global_history(limit=20)
    users   = list_all_users(limit=10)
    return templates.TemplateResponse("admin.html", {
        "request": request, "g": user,
        "stats": stats, "history": history, "users": users
    })

@app.get("/admin/users", response_class=HTMLResponse)
async def admin_users(request: Request, user: CurrentUser = Depends(require_admin)):
    from project.database import list_all_users
    users = list_all_users(limit=200)
    return templates.TemplateResponse("admin_users.html", {"request": request, "g": user, "users": users})

@app.get("/admin/logs", response_class=HTMLResponse)
async def admin_logs(request: Request, user: CurrentUser = Depends(require_admin)):
    from project.database import get_global_history
    history = get_global_history(limit=500)
    return templates.TemplateResponse("admin_logs.html", {"request": request, "g": user, "history": history})


# ── API Misc ───────────────────────────────────────────────────────────────────
@app.get("/api/suggested_facts")
async def suggested_facts():
    import random
    from knowledge_base import KNOWLEDGE_BASE
    facts = random.sample(KNOWLEDGE_BASE, min(3, len(KNOWLEDGE_BASE)))
    return JSONResponse({"success": True, "facts": [f["text"] for f in facts]})


# ── Error Handlers ─────────────────────────────────────────────────────────────
@app.exception_handler(404)
async def not_found(request: Request, exc):
    return JSONResponse({"error": "Not found"}, status_code=404)

@app.exception_handler(500)
async def server_error(request: Request, exc):
    return JSONResponse({"error": "Internal server error"}, status_code=500)


# ── Dev Server Entry Point (python fastapi_server.py) ─────────────────────────
if __name__ == "__main__":
    import uvicorn
    uvicorn.run("fastapi_server:app", host="0.0.0.0", port=8000, reload=True)