Spaces:
Running
Running
Anish-530 commited on
Commit ·
fe56754
1
Parent(s): 78b07c7
Fix login (audit_logs table + fault-tolerant audit), feedback graceful errors, remove raw AI explanation fallback, localStorage feedback persistence, Betterstack cloud logging
Browse files- backend/app/core/logger.py +31 -3
- backend/app/services/audit_service.py +39 -29
- backend/main.py +81 -78
- frontend/app/login/page.tsx +9 -4
- frontend/app/result/[id]/page.tsx +24 -10
backend/app/core/logger.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import sys
|
|
|
|
|
|
|
| 2 |
from loguru import logger
|
| 3 |
from app.core.config import settings
|
| 4 |
from pathlib import Path
|
|
@@ -19,8 +21,6 @@ log_dir = Path("app_logs")
|
|
| 19 |
log_dir.mkdir(parents=True, exist_ok=True)
|
| 20 |
|
| 21 |
# 4. Console Logger (JSON Serialized)
|
| 22 |
-
# Because serialize=True is used, the "request_id" from the 'extra' dict
|
| 23 |
-
# will automatically be included in the JSON output!
|
| 24 |
logger.add(
|
| 25 |
sys.stdout,
|
| 26 |
format="{message}",
|
|
@@ -30,7 +30,6 @@ logger.add(
|
|
| 30 |
)
|
| 31 |
|
| 32 |
# 5. File Logger
|
| 33 |
-
# We inject [{extra[request_id]}] into the format string so it prints beautifully
|
| 34 |
logger.add(
|
| 35 |
"app_logs/api_{time:YYYY-MM-DD}.log",
|
| 36 |
rotation="00:00",
|
|
@@ -40,3 +39,32 @@ logger.add(
|
|
| 40 |
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | [{extra[request_id]}] {name}:{function}:{line} - {message}",
|
| 41 |
enqueue=True
|
| 42 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
| 2 |
+
import os
|
| 3 |
+
import requests as _requests
|
| 4 |
from loguru import logger
|
| 5 |
from app.core.config import settings
|
| 6 |
from pathlib import Path
|
|
|
|
| 21 |
log_dir.mkdir(parents=True, exist_ok=True)
|
| 22 |
|
| 23 |
# 4. Console Logger (JSON Serialized)
|
|
|
|
|
|
|
| 24 |
logger.add(
|
| 25 |
sys.stdout,
|
| 26 |
format="{message}",
|
|
|
|
| 30 |
)
|
| 31 |
|
| 32 |
# 5. File Logger
|
|
|
|
| 33 |
logger.add(
|
| 34 |
"app_logs/api_{time:YYYY-MM-DD}.log",
|
| 35 |
rotation="00:00",
|
|
|
|
| 39 |
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | [{extra[request_id]}] {name}:{function}:{line} - {message}",
|
| 40 |
enqueue=True
|
| 41 |
)
|
| 42 |
+
|
| 43 |
+
# 6. Betterstack Logtail Cloud Sink (free remote logging)
|
| 44 |
+
# Set LOGTAIL_SOURCE_TOKEN in your HuggingFace Space secrets to enable.
|
| 45 |
+
_LOGTAIL_TOKEN = os.environ.get("LOGTAIL_SOURCE_TOKEN", "")
|
| 46 |
+
if _LOGTAIL_TOKEN:
|
| 47 |
+
def _logtail_sink(message):
|
| 48 |
+
record = message.record
|
| 49 |
+
try:
|
| 50 |
+
_requests.post(
|
| 51 |
+
"https://in.logs.betterstack.com",
|
| 52 |
+
headers={
|
| 53 |
+
"Authorization": f"Bearer {_LOGTAIL_TOKEN}",
|
| 54 |
+
"Content-Type": "application/json",
|
| 55 |
+
},
|
| 56 |
+
json={
|
| 57 |
+
"message": record["message"],
|
| 58 |
+
"level": record["level"].name,
|
| 59 |
+
"request_id": record["extra"].get("request_id", "SYSTEM"),
|
| 60 |
+
"logger": record["name"],
|
| 61 |
+
"function": record["function"],
|
| 62 |
+
"line": record["line"],
|
| 63 |
+
"dt": record["time"].isoformat(),
|
| 64 |
+
},
|
| 65 |
+
timeout=3,
|
| 66 |
+
)
|
| 67 |
+
except Exception:
|
| 68 |
+
pass # Never let cloud logging failure affect the app
|
| 69 |
+
|
| 70 |
+
logger.add(_logtail_sink, level="INFO", enqueue=True)
|
backend/app/services/audit_service.py
CHANGED
|
@@ -1,29 +1,39 @@
|
|
| 1 |
-
|
| 2 |
-
from
|
| 3 |
-
from
|
| 4 |
-
from
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.models.audit_model import AuditLog
|
| 4 |
+
from fastapi import Request
|
| 5 |
+
from app.core.request_id import get_request_id
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
def log_audit_event(
|
| 10 |
+
db: Session,
|
| 11 |
+
action: str,
|
| 12 |
+
user_id: int | None = None,
|
| 13 |
+
request: Request | None = None,
|
| 14 |
+
details: dict | None = None
|
| 15 |
+
):
|
| 16 |
+
try:
|
| 17 |
+
ip_address = None
|
| 18 |
+
if request and request.client:
|
| 19 |
+
ip_address = request.client.host
|
| 20 |
+
|
| 21 |
+
meta = details or {}
|
| 22 |
+
meta["request_id"] = get_request_id()
|
| 23 |
+
|
| 24 |
+
audit_entry = AuditLog(
|
| 25 |
+
user_id=user_id,
|
| 26 |
+
action=action,
|
| 27 |
+
ip=ip_address,
|
| 28 |
+
metadata_info=meta
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
db.add(audit_entry)
|
| 32 |
+
db.commit()
|
| 33 |
+
except Exception as e:
|
| 34 |
+
# Never let an audit log failure crash the main request
|
| 35 |
+
try:
|
| 36 |
+
db.rollback()
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
+
logger.warning(f"Audit log write failed (non-critical): {e}")
|
backend/main.py
CHANGED
|
@@ -1,78 +1,81 @@
|
|
| 1 |
-
from fastapi import FastAPI
|
| 2 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
-
from fastapi.staticfiles import StaticFiles
|
| 4 |
-
from contextlib import asynccontextmanager
|
| 5 |
-
import os
|
| 6 |
-
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
| 7 |
-
from slowapi import _rate_limit_exceeded_handler
|
| 8 |
-
from slowapi.errors import RateLimitExceeded
|
| 9 |
-
from app.api.user_routes import router as user_router
|
| 10 |
-
from app.api.auth_routes import router as auth_router
|
| 11 |
-
from app.api.profile_routes import router as profile_router
|
| 12 |
-
from app.api.file_routes import router as file_router
|
| 13 |
-
from app.api.feedback_routes import router as feedback_router
|
| 14 |
-
from app.api.admin_routes import router as admin_router
|
| 15 |
-
from app.api.metric_routes import router as metrics_router
|
| 16 |
-
from app.api.user_issue_routes import router as user_issue_router
|
| 17 |
-
from app.core.logging_middleware import APILoggingMiddleware
|
| 18 |
-
from app.
|
| 19 |
-
from app.
|
| 20 |
-
from
|
| 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 |
-
app.
|
| 65 |
-
|
| 66 |
-
app.
|
| 67 |
-
app.include_router(
|
| 68 |
-
app.include_router(
|
| 69 |
-
app.include_router(
|
| 70 |
-
app.include_router(
|
| 71 |
-
app.
|
| 72 |
-
app.include_router(
|
| 73 |
-
app.
|
| 74 |
-
app.
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.staticfiles import StaticFiles
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
import os
|
| 6 |
+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
| 7 |
+
from slowapi import _rate_limit_exceeded_handler
|
| 8 |
+
from slowapi.errors import RateLimitExceeded
|
| 9 |
+
from app.api.user_routes import router as user_router
|
| 10 |
+
from app.api.auth_routes import router as auth_router
|
| 11 |
+
from app.api.profile_routes import router as profile_router
|
| 12 |
+
from app.api.file_routes import router as file_router
|
| 13 |
+
from app.api.feedback_routes import router as feedback_router
|
| 14 |
+
from app.api.admin_routes import router as admin_router
|
| 15 |
+
from app.api.metric_routes import router as metrics_router
|
| 16 |
+
from app.api.user_issue_routes import router as user_issue_router
|
| 17 |
+
from app.core.logging_middleware import APILoggingMiddleware
|
| 18 |
+
from app.core.limiter import limiter
|
| 19 |
+
from app.middleware.security_headers import SecurityHeadersMiddleware
|
| 20 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 21 |
+
|
| 22 |
+
@asynccontextmanager
|
| 23 |
+
async def lifespan(app: FastAPI):
|
| 24 |
+
# Auto-create any missing tables (e.g. audit_logs) without dropping existing data
|
| 25 |
+
from app.db.database import Base, engine
|
| 26 |
+
import app.models.audit_model # noqa: ensure AuditLog is registered with Base
|
| 27 |
+
import app.models.user_model # noqa
|
| 28 |
+
import app.models.file_model # noqa
|
| 29 |
+
Base.metadata.create_all(bind=engine, checkfirst=True)
|
| 30 |
+
yield
|
| 31 |
+
|
| 32 |
+
is_production = os.environ.get("ENVIRONMENT", "development") == "production"
|
| 33 |
+
|
| 34 |
+
app = FastAPI(
|
| 35 |
+
title="Spotix",
|
| 36 |
+
lifespan=lifespan,
|
| 37 |
+
docs_url=None if is_production else "/docs",
|
| 38 |
+
redoc_url=None if is_production else "/redoc",
|
| 39 |
+
openapi_url=None if is_production else "/openapi.json"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
|
| 43 |
+
|
| 44 |
+
app.add_middleware(
|
| 45 |
+
SessionMiddleware,
|
| 46 |
+
secret_key=os.environ.get("SESSION_SECRET_KEY", "neural-vault-oauth-session-secret-9912"),
|
| 47 |
+
https_only=is_production
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
frontend_url = os.environ.get("FRONTEND_URL", "http://localhost:3000")
|
| 51 |
+
origins = [frontend_url]
|
| 52 |
+
if frontend_url != "http://localhost:3000":
|
| 53 |
+
origins.append("http://localhost:3000")
|
| 54 |
+
|
| 55 |
+
app.add_middleware(
|
| 56 |
+
CORSMiddleware,
|
| 57 |
+
allow_origins=origins,
|
| 58 |
+
allow_credentials=True,
|
| 59 |
+
allow_methods=["*"],
|
| 60 |
+
allow_headers=["*"],
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Trigger reload
|
| 64 |
+
app.mount("/static", StaticFiles(directory="."), name="static")
|
| 65 |
+
|
| 66 |
+
app.state.limiter = limiter
|
| 67 |
+
app.include_router(profile_router)
|
| 68 |
+
app.include_router(user_router)
|
| 69 |
+
app.include_router(auth_router)
|
| 70 |
+
app.include_router(file_router)
|
| 71 |
+
app.include_router(feedback_router)
|
| 72 |
+
app.include_router(admin_router)
|
| 73 |
+
app.include_router(metrics_router)
|
| 74 |
+
app.include_router(user_issue_router)
|
| 75 |
+
app.add_middleware(APILoggingMiddleware)
|
| 76 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 77 |
+
app.add_middleware(SecurityHeadersMiddleware)
|
| 78 |
+
|
| 79 |
+
@app.get("/")
|
| 80 |
+
def home():
|
| 81 |
+
return {"message": "Backend running successfully"}
|
frontend/app/login/page.tsx
CHANGED
|
@@ -95,11 +95,16 @@ export default function LoginPage() {
|
|
| 95 |
setLoading(false);
|
| 96 |
}
|
| 97 |
} catch (err: any) {
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
setError(
|
| 101 |
} else {
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
setLoading(false);
|
| 105 |
}
|
|
|
|
| 95 |
setLoading(false);
|
| 96 |
}
|
| 97 |
} catch (err: any) {
|
| 98 |
+
if (!err.response) {
|
| 99 |
+
// Network error — backend unreachable or CORS
|
| 100 |
+
setError("Unable to reach the server. Please try again shortly.");
|
| 101 |
} else {
|
| 102 |
+
const detail = err.response?.data?.detail;
|
| 103 |
+
if (Array.isArray(detail)) {
|
| 104 |
+
setError(detail[0]?.msg || "Validation error. Please check your inputs.");
|
| 105 |
+
} else {
|
| 106 |
+
setError(detail || "Authentication failed. Please check your credentials.");
|
| 107 |
+
}
|
| 108 |
}
|
| 109 |
setLoading(false);
|
| 110 |
}
|
frontend/app/result/[id]/page.tsx
CHANGED
|
@@ -186,13 +186,9 @@ export default function ResultPage() {
|
|
| 186 |
label = 'Real';
|
| 187 |
}
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
if (mappedExplanations.length > 0) {
|
| 193 |
-
fileData.ai_explanation = mappedExplanations.join('\n').trim();
|
| 194 |
-
}
|
| 195 |
-
}
|
| 196 |
|
| 197 |
const freqMatch = fileData.result.match(/FREQ:\s*([\d\.]+)/);
|
| 198 |
const cnnMatch = fileData.result.match(/CNN:\s*([\d\.]+)/);
|
|
@@ -218,6 +214,16 @@ export default function ResultPage() {
|
|
| 218 |
const isVideo = fileData?.type?.startsWith("video/");
|
| 219 |
const allLoaded = isVideo ? mediaLoaded : (mediaLoaded && (!heatmapUrl || heatmapLoaded));
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
useEffect(() => {
|
| 222 |
if (allLoaded && isAuthenticated && !feedbackSubmitted && verdictStatus) {
|
| 223 |
const timer = setTimeout(() => setShowFeedbackPopup(true), 2000);
|
|
@@ -226,22 +232,30 @@ export default function ResultPage() {
|
|
| 226 |
}, [allLoaded, isAuthenticated, feedbackSubmitted, verdictStatus]);
|
| 227 |
|
| 228 |
const submitFeedback = async (selectedLabel: string) => {
|
|
|
|
| 229 |
setFeedbackLoading(true);
|
| 230 |
setFeedbackError(null);
|
| 231 |
try {
|
| 232 |
await apiLayer.submitFeedback({
|
| 233 |
-
file_id:
|
| 234 |
label: selectedLabel.toLowerCase(),
|
| 235 |
confidence: confidenceVal ?? 0,
|
| 236 |
freq_score: freqScore ?? 0,
|
| 237 |
cnn_score: cnnScore ?? 0
|
| 238 |
});
|
|
|
|
|
|
|
| 239 |
setFeedbackSubmitted(true);
|
| 240 |
setShowFeedbackPopup(false);
|
| 241 |
setShowFeedbackModal(false);
|
| 242 |
} catch (err: any) {
|
| 243 |
-
const
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
} finally {
|
| 246 |
setFeedbackLoading(false);
|
| 247 |
}
|
|
|
|
| 186 |
label = 'Real';
|
| 187 |
}
|
| 188 |
|
| 189 |
+
// Do NOT fall back to parsing result lines as explanation -
|
| 190 |
+
// those are raw model output labels, not natural language.
|
| 191 |
+
// ai_explanation from the API (LLM-generated) is used directly below.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
const freqMatch = fileData.result.match(/FREQ:\s*([\d\.]+)/);
|
| 194 |
const cnnMatch = fileData.result.match(/CNN:\s*([\d\.]+)/);
|
|
|
|
| 214 |
const isVideo = fileData?.type?.startsWith("video/");
|
| 215 |
const allLoaded = isVideo ? mediaLoaded : (mediaLoaded && (!heatmapUrl || heatmapLoaded));
|
| 216 |
|
| 217 |
+
// Check localStorage so reopening a result page doesn't re-show the popup
|
| 218 |
+
useEffect(() => {
|
| 219 |
+
if (fileData?.id) {
|
| 220 |
+
const key = `spotix_feedback_${fileData.id}`;
|
| 221 |
+
if (localStorage.getItem(key) === 'done') {
|
| 222 |
+
setFeedbackSubmitted(true);
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
}, [fileData?.id]);
|
| 226 |
+
|
| 227 |
useEffect(() => {
|
| 228 |
if (allLoaded && isAuthenticated && !feedbackSubmitted && verdictStatus) {
|
| 229 |
const timer = setTimeout(() => setShowFeedbackPopup(true), 2000);
|
|
|
|
| 232 |
}, [allLoaded, isAuthenticated, feedbackSubmitted, verdictStatus]);
|
| 233 |
|
| 234 |
const submitFeedback = async (selectedLabel: string) => {
|
| 235 |
+
if (!fileData) return;
|
| 236 |
setFeedbackLoading(true);
|
| 237 |
setFeedbackError(null);
|
| 238 |
try {
|
| 239 |
await apiLayer.submitFeedback({
|
| 240 |
+
file_id: fileData.id, // use the integer id from data, not the URL string
|
| 241 |
label: selectedLabel.toLowerCase(),
|
| 242 |
confidence: confidenceVal ?? 0,
|
| 243 |
freq_score: freqScore ?? 0,
|
| 244 |
cnn_score: cnnScore ?? 0
|
| 245 |
});
|
| 246 |
+
// Persist so reopening this page won't show popup again
|
| 247 |
+
localStorage.setItem(`spotix_feedback_${fileData.id}`, 'done');
|
| 248 |
setFeedbackSubmitted(true);
|
| 249 |
setShowFeedbackPopup(false);
|
| 250 |
setShowFeedbackModal(false);
|
| 251 |
} catch (err: any) {
|
| 252 |
+
const status = err.response?.status;
|
| 253 |
+
if (status === 400) {
|
| 254 |
+
// e.g. "Feedback already submitted for this file."
|
| 255 |
+
setFeedbackError("You've already rated this scan.");
|
| 256 |
+
} else {
|
| 257 |
+
setFeedbackError("Something went wrong. Please try again.");
|
| 258 |
+
}
|
| 259 |
} finally {
|
| 260 |
setFeedbackLoading(false);
|
| 261 |
}
|