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 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
- from sqlalchemy.orm import Session
2
- from app.models.audit_model import AuditLog
3
- from fastapi import Request
4
- from app.core.request_id import get_request_id
5
-
6
- def log_audit_event(
7
- db: Session,
8
- action: str,
9
- user_id: int | None = None,
10
- request: Request | None = None,
11
- details: dict | None = None
12
- ):
13
- ip_address = None
14
- if request and request.client:
15
- ip_address = request.client.host
16
-
17
- # ip_address = request.headers.get("X-Forwarded-For", request.client.host)
18
- meta = details or {}
19
- meta["request_id"] = get_request_id()
20
-
21
- audit_entry = AuditLog(
22
- user_id=user_id,
23
- action=action,
24
- ip=ip_address,
25
- metadata_info=meta
26
- )
27
-
28
- db.add(audit_entry)
29
- db.commit()
 
 
 
 
 
 
 
 
 
 
 
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.api.user_routes import router as user_router
19
- from app.core.limiter import limiter
20
- from app.middleware.security_headers import SecurityHeadersMiddleware
21
- from starlette.middleware.sessions import SessionMiddleware
22
-
23
- @asynccontextmanager
24
- async def lifespan(app: FastAPI):
25
- pass
26
- yield
27
-
28
- is_production = os.environ.get("ENVIRONMENT", "development") == "production"
29
-
30
- app = FastAPI(
31
- title="Spotix",
32
- lifespan=lifespan,
33
- docs_url=None if is_production else "/docs",
34
- redoc_url=None if is_production else "/redoc",
35
- openapi_url=None if is_production else "/openapi.json"
36
- )
37
-
38
- app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
39
-
40
- app.add_middleware(
41
- SessionMiddleware,
42
- secret_key=os.environ.get("SESSION_SECRET_KEY", "neural-vault-oauth-session-secret-9912"),
43
- https_only=is_production
44
- )
45
-
46
- frontend_url = os.environ.get("FRONTEND_URL", "http://localhost:3000")
47
- origins = [frontend_url]
48
- if frontend_url != "http://localhost:3000":
49
- origins.append("http://localhost:3000")
50
-
51
- app.add_middleware(
52
- CORSMiddleware,
53
- allow_origins=origins,
54
- allow_credentials=True,
55
- allow_methods=["*"],
56
- allow_headers=["*"],
57
- )
58
-
59
- # Trigger reload
60
- app.mount("/static", StaticFiles(directory="."), name="static")
61
-
62
- app.state.limiter = limiter
63
- app.include_router(profile_router)
64
- app.include_router(user_router)
65
- app.include_router(auth_router)
66
- app.include_router(file_router)
67
- app.include_router(feedback_router)
68
- app.include_router(admin_router)
69
- app.include_router(metrics_router)
70
- app.include_router(user_issue_router)
71
- app.add_middleware(APILoggingMiddleware)
72
- app.include_router(profile_router)
73
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
74
- app.add_middleware(SecurityHeadersMiddleware)
75
-
76
- @app.get("/")
77
- def home():
78
- return {"message": "Backend running successfully"}
 
 
 
 
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
- const detail = err.response?.data?.detail;
99
- if (Array.isArray(detail)) {
100
- setError(detail[0]?.msg || "Validation Error");
101
  } else {
102
- setError(detail || "Authentication Failed");
 
 
 
 
 
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
- if (parts.length > 1 && !fileData.ai_explanation) {
190
- // Ignore FREQ and CNN legacy splits for strictly explanation maps
191
- const mappedExplanations = parts.slice(1).filter(p => !p.includes('FREQ:') && !p.includes('CNN:') && !p.includes('NSFW:'));
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: parseInt(fileId),
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 msg = err.response?.data?.detail || "Failed to submit. Please try again.";
244
- setFeedbackError(typeof msg === 'string' ? msg : JSON.stringify(msg));
 
 
 
 
 
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
  }