Anish-530 commited on
Commit
0edd56d
·
1 Parent(s): 5603f49

[Deployment Phase 1 & 2] - Neon DB and R2 with working turnstile in login page implemented

Browse files
backend/app/api/file_routes.py CHANGED
@@ -12,6 +12,7 @@ from app.core.limiter import limiter
12
  from app.security.turnstile import verify_turnstile_token
13
  from datetime import datetime, UTC
14
  from app.models.file_model import File
 
15
  import base64
16
  import struct
17
 
@@ -39,8 +40,8 @@ def format_file_response(f):
39
  base_dict = {
40
  "id": encode_id(f.id),
41
  "filename": f.filename,
42
- "filepath": f.filepath,
43
- "heatmap_path": f.heatmap_path,
44
  "type": f.filetype,
45
  "size": f.filesize,
46
  "status": f.status,
@@ -95,12 +96,10 @@ def get_file(
95
  async def upload_file(
96
  request: Request,
97
  file: UploadFile = FastAPIFile(...),
98
- cf_turnstile_response: str = Header(None, alias="cf-turnstile-response"),
99
  db: Session = Depends(get_db),
100
  current_user: User = Depends(get_optional_user)
101
  ):
102
  client_ip = request.client.host
103
- await verify_turnstile_token(cf_turnstile_response, client_ip)
104
 
105
  if not current_user:
106
  today = datetime.now(UTC).date()
 
12
  from app.security.turnstile import verify_turnstile_token
13
  from datetime import datetime, UTC
14
  from app.models.file_model import File
15
+ from app.core.storage import active_storage
16
  import base64
17
  import struct
18
 
 
40
  base_dict = {
41
  "id": encode_id(f.id),
42
  "filename": f.filename,
43
+ "filepath": active_storage.get_presigned_url(f.filepath) if f.filepath else None,
44
+ "heatmap_path": active_storage.get_presigned_url(f.heatmap_path) if f.heatmap_path else None,
45
  "type": f.filetype,
46
  "size": f.filesize,
47
  "status": f.status,
 
96
  async def upload_file(
97
  request: Request,
98
  file: UploadFile = FastAPIFile(...),
 
99
  db: Session = Depends(get_db),
100
  current_user: User = Depends(get_optional_user)
101
  ):
102
  client_ip = request.client.host
 
103
 
104
  if not current_user:
105
  today = datetime.now(UTC).date()
backend/app/core/config.py CHANGED
@@ -20,6 +20,11 @@ class Settings(BaseSettings):
20
  SMTP_SERVER: str | None = None
21
  FRONTEND_URL: str = "http://localhost:3000"
22
  TURNSTILE_SECRET_KEY: str | None = None
 
 
 
 
 
23
 
24
  model_config = SettingsConfigDict(
25
  env_file=".env",
 
20
  SMTP_SERVER: str | None = None
21
  FRONTEND_URL: str = "http://localhost:3000"
22
  TURNSTILE_SECRET_KEY: str | None = None
23
+
24
+ R2_ENDPOINT_URL: str | None = None
25
+ R2_ACCESS_KEY_ID: str | None = None
26
+ R2_SECRET_ACCESS_KEY: str | None = None
27
+ R2_BUCKET_NAME: str | None = None
28
 
29
  model_config = SettingsConfigDict(
30
  env_file=".env",
backend/app/core/processor.py CHANGED
@@ -14,6 +14,9 @@ from app.ai.feature_extractor import extract_features
14
  from app.ai.attribution import generate_attribution
15
  from app.ai.explanation_engine import generated_structured_explanation
16
  from app.ai.explanation_formatter import format_explanation_with_llm
 
 
 
17
 
18
  def process_file(file_id: int, db: Session):
19
  file = db.query(File).filter(File.id == file_id).first()
@@ -21,20 +24,33 @@ def process_file(file_id: int, db: Session):
21
  if not file:
22
  return
23
 
 
 
24
  safe_heatmap_name = f"{uuid.uuid4().hex}.png"
25
- heatmap_path = f"uploads/heatmaps/{safe_heatmap_name}"
 
 
26
  file.status = "PROCESSING"
27
-
28
  active_version = model_loader.get_latest_model_version()
29
  file.model_version_used = active_version
30
  db.commit()
31
 
32
  try:
33
- nsfw_result = detect_ai_image(file.filepath)
34
- features = extract_features(file.filepath)
35
  label, prob = predict_ai(features["frequency_score"], features["cnn_score"])
36
- attribution_data = generate_attribution(file.filepath, heatmap_path)
37
- file.heatmap_path = attribution_data["heatmap_path"]
 
 
 
 
 
 
 
 
 
 
38
  structured_reasoning = generated_structured_explanation(features, prob)
39
  natural_reasoning = format_explanation_with_llm(structured_reasoning)
40
 
@@ -47,5 +63,11 @@ def process_file(file_id: int, db: Session):
47
  file.status = "FAILED"
48
  file.result = str(e)
49
 
 
 
 
 
 
 
50
  db.commit()
51
  db.close()
 
14
  from app.ai.attribution import generate_attribution
15
  from app.ai.explanation_engine import generated_structured_explanation
16
  from app.ai.explanation_formatter import format_explanation_with_llm
17
+ from app.core.storage import active_storage
18
+ import os
19
+
20
 
21
  def process_file(file_id: int, db: Session):
22
  file = db.query(File).filter(File.id == file_id).first()
 
24
  if not file:
25
  return
26
 
27
+ local_path = active_storage.download_to_temp(file.filepath)
28
+
29
  safe_heatmap_name = f"{uuid.uuid4().hex}.png"
30
+ os.makedirs("uploads/heatmaps", exist_ok=True)
31
+ local_heatmap_path = f"uploads/heatmaps/{safe_heatmap_name}"
32
+
33
  file.status = "PROCESSING"
 
34
  active_version = model_loader.get_latest_model_version()
35
  file.model_version_used = active_version
36
  db.commit()
37
 
38
  try:
39
+ nsfw_result = detect_ai_image(local_path)
40
+ features = extract_features(local_path)
41
  label, prob = predict_ai(features["frequency_score"], features["cnn_score"])
42
+ attribution_data = generate_attribution(local_path, local_heatmap_path)
43
+
44
+ class MockFile:
45
+ def __init__(self, f):
46
+ self.file = f
47
+ self.content_type = "image/png"
48
+
49
+ with open(local_heatmap_path, "rb") as hf:
50
+ mock_hf = MockFile(hf)
51
+ r2_heatmap_key = active_storage.save(mock_hf, f"heatmaps/{safe_heatmap_name}")
52
+
53
+ file.heatmap_path = r2_heatmap_key
54
  structured_reasoning = generated_structured_explanation(features, prob)
55
  natural_reasoning = format_explanation_with_llm(structured_reasoning)
56
 
 
63
  file.status = "FAILED"
64
  file.result = str(e)
65
 
66
+ finally:
67
+ if 'local_path' in locals() and os.path.exists(local_path) and getattr(active_storage, '__class__').__name__ == "R2StorageProvider":
68
+ os.remove(local_path)
69
+ if 'local_heatmap_path' in locals() and os.path.exists(local_heatmap_path):
70
+ os.remove(local_heatmap_path)
71
+
72
  db.commit()
73
  db.close()
backend/app/core/storage.py CHANGED
@@ -1,32 +1,118 @@
1
- import os
2
- from abc import ABC, abstractmethod
3
- from fastapi import UploadFile
4
-
5
- class StorageProvider(ABC):
6
- @abstractmethod
7
- def save(self, file: UploadFile, filename: str) -> str:
8
- pass
9
-
10
- @abstractmethod
11
- def delete(self, filepath: str) -> bool:
12
- pass
13
-
14
- class LocalStorageProvider(StorageProvider):
15
- def __init__(self, upload_dir: str = "uploads"):
16
- self.upload_dir = upload_dir
17
- os.makedirs(self.upload_dir, exist_ok=True)
18
-
19
- def save(self, file: UploadFile, filename: str) -> str:
20
- filepath = os.path.join(self.upload_dir, filename)
21
- with open(filepath, "wb") as buffer:
22
- while chunk := file.file.read(1024 * 1024):
23
- buffer.write(chunk)
24
- return filepath
25
-
26
- def delete(self, filepath: str) -> bool:
27
- if os.path.exists(filepath):
28
- os.remove(filepath)
29
- return True
30
- return False
31
-
32
- active_storage: StorageProvider = LocalStorageProvider()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+ from botocore.client import Config
5
+ from abc import ABC, abstractmethod
6
+ from fastapi import UploadFile
7
+ from app.core.config import settings
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class StorageProvider(ABC):
13
+ @abstractmethod
14
+ def save(self, file: UploadFile, filename: str) -> str:
15
+ pass
16
+
17
+ @abstractmethod
18
+ def delete(self, filepath: str) -> bool:
19
+ pass
20
+
21
+ @abstractmethod
22
+ def get_presigned_url(self, filepath: str) -> str:
23
+ pass
24
+
25
+ @abstractmethod
26
+ def download_to_temp(self, filepath: str) -> str:
27
+ pass
28
+
29
+ class LocalStorageProvider(StorageProvider):
30
+ def __init__(self, upload_dir: str = "uploads"):
31
+ self.upload_dir = upload_dir
32
+ os.makedirs(self.upload_dir, exist_ok=True)
33
+
34
+ def save(self, file: UploadFile, filename: str) -> str:
35
+ filepath = os.path.join(self.upload_dir, filename)
36
+ with open(filepath, "wb") as buffer:
37
+ while chunk := file.file.read(1024 * 1024):
38
+ buffer.write(chunk)
39
+ return filepath
40
+
41
+ def delete(self, filepath: str) -> bool:
42
+ if os.path.exists(filepath):
43
+ os.remove(filepath)
44
+ return True
45
+ return False
46
+
47
+ def get_presigned_url(self, filepath: str) -> str:
48
+ # Local storage doesn't need presigned URLs, returning the path for local streaming
49
+ return filepath
50
+
51
+ def download_to_temp(self, filepath: str) -> str:
52
+ # It's already local
53
+ return filepath
54
+
55
+ class R2StorageProvider(StorageProvider):
56
+ def __init__(self):
57
+ self.s3_client = boto3.client(
58
+ service_name="s3",
59
+ endpoint_url=settings.R2_ENDPOINT_URL,
60
+ aws_access_key_id=settings.R2_ACCESS_KEY_ID,
61
+ aws_secret_access_key=settings.R2_SECRET_ACCESS_KEY,
62
+ region_name="auto",
63
+ config=Config(signature_version="s3v4")
64
+ )
65
+ self.bucket_name = settings.R2_BUCKET_NAME
66
+
67
+ def save(self, file: UploadFile, filename: str) -> str:
68
+ r2_key = f"uploads/{filename}"
69
+ file.file.seek(0)
70
+ self.s3_client.upload_fileobj(
71
+ file.file,
72
+ self.bucket_name,
73
+ r2_key,
74
+ ExtraArgs={"ContentType": file.content_type}
75
+ )
76
+ return r2_key
77
+
78
+ def delete(self, filepath: str) -> bool:
79
+ try:
80
+ self.s3_client.delete_object(Bucket=self.bucket_name, Key=filepath)
81
+ return True
82
+ except ClientError as e:
83
+ logger.error(f"Error deleting file from R2: {e}")
84
+ return False
85
+
86
+ def get_presigned_url(self, filepath: str) -> str:
87
+ try:
88
+ url = self.s3_client.generate_presigned_url(
89
+ 'get_object',
90
+ Params={'Bucket': self.bucket_name, 'Key': filepath},
91
+ ExpiresIn=3600 # 1 hour expiry
92
+ )
93
+ return url
94
+ except ClientError as e:
95
+ logger.error(f"Error generating presigned url: {e}")
96
+ return filepath
97
+
98
+ def download_to_temp(self, filepath: str) -> str:
99
+ import tempfile
100
+ ext = filepath.split(".")[-1] if "." in filepath else "tmp"
101
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}")
102
+ temp_file.close()
103
+ try:
104
+ self.s3_client.download_file(self.bucket_name, filepath, temp_file.name)
105
+ return temp_file.name
106
+ except ClientError as e:
107
+ logger.error(f"Error downloading file from R2: {e}")
108
+ import os
109
+ if os.path.exists(temp_file.name):
110
+ os.remove(temp_file.name)
111
+ raise
112
+
113
+ if settings.R2_ENDPOINT_URL and settings.R2_ACCESS_KEY_ID and settings.R2_SECRET_ACCESS_KEY and settings.R2_BUCKET_NAME:
114
+ active_storage: StorageProvider = R2StorageProvider()
115
+ logger.info("Using R2StorageProvider for active_storage")
116
+ else:
117
+ active_storage: StorageProvider = LocalStorageProvider()
118
+ logger.info("Using LocalStorageProvider for active_storage (R2 config missing)")
backend/app/db/database.py CHANGED
@@ -1,8 +1,19 @@
1
  from sqlalchemy import create_engine
2
  from sqlalchemy.orm import sessionmaker, declarative_base
3
  from app.core.config import settings
4
-
5
- engine = create_engine(settings.DATABASE_URL)
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  SessionLocal = sessionmaker(
8
  autocommit=False,
 
1
  from sqlalchemy import create_engine
2
  from sqlalchemy.orm import sessionmaker, declarative_base
3
  from app.core.config import settings
4
+ if settings.DATABASE_URL.startswith("sqlite"):
5
+ engine = create_engine(
6
+ settings.DATABASE_URL,
7
+ connect_args={"check_same_thread": False}
8
+ )
9
+ else:
10
+ engine = create_engine(
11
+ settings.DATABASE_URL,
12
+ pool_pre_ping=True,
13
+ pool_recycle=300,
14
+ pool_size=10,
15
+ max_overflow=20
16
+ )
17
 
18
  SessionLocal = sessionmaker(
19
  autocommit=False,
backend/app/security/turnstile.py CHANGED
@@ -17,7 +17,7 @@ async def verify_turnstile_token(token: str, ip_address: str = None) -> bool:
17
 
18
  verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
19
  payload = {
20
- "secret": settings.TURNSTILE_SECRET_KEY,
21
  "response": token
22
  }
23
 
 
17
 
18
  verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
19
  payload = {
20
+ "secret": settings.TURNSTILE_SECRET_KEY or "1x0000000000000000000000000000000AA",
21
  "response": token
22
  }
23
 
backend/app/services/file_delete_service.py CHANGED
@@ -13,7 +13,10 @@ def delete_file_service(db: Session, file_id: int, user_id: int):
13
  if file.owner_id != user_id:
14
  raise HTTPException(status_code=403, detail="Not Authorized to delete this file")
15
 
16
- active_storage.delete(file.filepath)
 
 
 
17
 
18
  # Delete related jobs first to prevent FK IntegrityError in SQLite
19
  from app.models.job_model import Job
 
13
  if file.owner_id != user_id:
14
  raise HTTPException(status_code=403, detail="Not Authorized to delete this file")
15
 
16
+ if file.filepath:
17
+ active_storage.delete(file.filepath)
18
+ if file.heatmap_path:
19
+ active_storage.delete(file.heatmap_path)
20
 
21
  # Delete related jobs first to prevent FK IntegrityError in SQLite
22
  from app.models.job_model import Job
backend/app/services/video_processor.py CHANGED
@@ -1,116 +1,142 @@
1
- import time
2
- import logging
3
- import os
4
- import json
5
- from sqlalchemy.orm import Session
6
- from app.models.file_model import File
7
- from app.ai.video.frame_extractor import extract_frames
8
- from app.ai.video.frame_detector import analyze_frame
9
- from app.ai.video.motion_detector import compute_motion_anomaly
10
- from app.ai.video.noise_entropy_detector import compute_noise_entropy_anomaly
11
- from app.ai.video.metadata_analyzer import analyze_metadata
12
- from app.ai.video.aggregator import aggregate_scores
13
- from app.ai.video.diffusion_spectrum_analyzer import compute_diffusion_spectrum_anomaly
14
- from app.ai.video.keyframe_heatmap import generate_video_anomaly_keyframe
15
- from app.ai.video.anomaly_gif_maker import generate_anomaly_gif
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
- def process_video_pipeline(file_id: int, file_path: str, db: Session):
20
- start_time = time.time()
21
- logger.info(f"Starting Video Pipeline for file_id: {file_id}")
22
-
23
- try:
24
- db_file = db.query(File).filter(File.id == file_id).first()
25
- if not db_file:
26
- logger.error(f"File ID {file_id} not found in DB.")
27
- return
28
-
29
- md_score, md_dict = analyze_metadata(file_path)
30
-
31
- frame_scores = []
32
- motion_scores = []
33
- noise_scores = []
34
- diffusion_scores = []
35
-
36
- previous_frame = None
37
- highest_ai_score = 0
38
- most_suspect_frame_idx = 0
39
- all_extracted_frames = []
40
- timeline_data = []
41
-
42
- for frame, timestamp_sec in extract_frames(file_path, sample_rate=15, max_frames=50):
43
- all_extracted_frames.append(frame.copy())
44
-
45
- # Module A: ViT Image Forensic Analysis
46
- f_score = analyze_frame(frame)
47
- frame_scores.append(f_score)
48
-
49
- # Add this specific second to the UI Scrubber Data
50
- timeline_data.append({"time": round(timestamp_sec, 2), "ai_score": round(f_score, 3)})
51
-
52
- if f_score > highest_ai_score:
53
- highest_ai_score = f_score
54
- most_suspect_frame_idx = len(all_extracted_frames) - 1
55
-
56
- # Module B: Microscopic Silicon Noise Analysis
57
- n_score = compute_noise_entropy_anomaly(frame)
58
- if n_score is not None:
59
- noise_scores.append(n_score)
60
-
61
- # Module C: Diffusion Spectrum Analysis (FFT)
62
- d_score = compute_diffusion_spectrum_anomaly(frame)
63
- diffusion_scores.append(d_score)
64
-
65
- # Module D: Farneback Optical Flow
66
- if previous_frame is not None:
67
- m_score = compute_motion_anomaly(previous_frame, frame)
68
-
69
- if m_score is not None:
70
- motion_scores.append(m_score)
71
-
72
- previous_frame = frame
73
-
74
- if all_extracted_frames:
75
- # 1. Generate the 2-second GIF Clip
76
- gif_path = generate_anomaly_gif(all_extracted_frames, most_suspect_frame_idx)
77
- db_file.heatmap_path = gif_path # Overwriting the static image column with the GIF file!
78
-
79
- # 2. Save the JSON string for the Line Chart Scrubber
80
- db_file.timeline_data = json.dumps(timeline_data)
81
-
82
- avg_motion = sum(motion_scores)/len(motion_scores) if motion_scores else None
83
- avg_noise = sum(noise_scores)/len(noise_scores) if noise_scores else None
84
- avg_diffusion = sum(diffusion_scores)/len(diffusion_scores) if diffusion_scores else 0.0
85
-
86
- final_verdict = aggregate_scores(
87
- frame_scores=frame_scores,
88
- motion_score=avg_motion,
89
- noise_score=avg_noise,
90
- diffusion_score=avg_diffusion,
91
- metadata_score=md_score
92
- )
93
-
94
- db_file.status = "Completed"
95
- db_file.result = f"{final_verdict['label']} ({final_verdict['probability']*100:.1f}%)"
96
- db_file.confidence = final_verdict['probability']
97
- db_file.ai_explanation = final_verdict['explanation']
98
-
99
- db.commit()
100
-
101
- elapsed = time.time() - start_time
102
- logger.info(f"Successfully processed video {file_id} in {elapsed:.2f} seconds.")
103
- logger.info(f"Verdict: {db_file.result}")
104
-
105
- except Exception as e:
106
- logger.error(f"FATAL Pipeline Crash for file_id {file_id}: {str(e)}")
107
-
108
- try:
109
- db_file = db.query(File).filter(File.id == file_id).first()
110
-
111
- if db_file:
112
- db_file.status = "Failed"
113
- db_file.result = str(e)
114
- db.commit()
115
- except:
116
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import logging
3
+ import os
4
+ import json
5
+ from sqlalchemy.orm import Session
6
+ from app.models.file_model import File
7
+ from app.ai.video.frame_extractor import extract_frames
8
+ from app.ai.video.frame_detector import analyze_frame
9
+ from app.ai.video.motion_detector import compute_motion_anomaly
10
+ from app.ai.video.noise_entropy_detector import compute_noise_entropy_anomaly
11
+ from app.ai.video.metadata_analyzer import analyze_metadata
12
+ from app.ai.video.aggregator import aggregate_scores
13
+ from app.ai.video.diffusion_spectrum_analyzer import compute_diffusion_spectrum_anomaly
14
+ from app.ai.video.keyframe_heatmap import generate_video_anomaly_keyframe
15
+ from app.ai.video.anomaly_gif_maker import generate_anomaly_gif
16
+ from app.core.storage import active_storage
17
+ import tempfile
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ def process_video_pipeline(file_id: int, file_path: str, db: Session):
22
+ start_time = time.time()
23
+ logger.info(f"Starting Video Pipeline for file_id: {file_id}")
24
+
25
+ try:
26
+ db_file = db.query(File).filter(File.id == file_id).first()
27
+ if not db_file:
28
+ logger.error(f"File ID {file_id} not found in DB.")
29
+ return
30
+
31
+ local_path = active_storage.download_to_temp(file_path)
32
+
33
+ md_score, md_dict = analyze_metadata(local_path)
34
+
35
+ frame_scores = []
36
+ motion_scores = []
37
+ noise_scores = []
38
+ diffusion_scores = []
39
+
40
+ previous_frame = None
41
+ highest_ai_score = 0
42
+ most_suspect_frame_idx = 0
43
+ all_extracted_frames = []
44
+ timeline_data = []
45
+
46
+ for frame, timestamp_sec in extract_frames(local_path, sample_rate=15, max_frames=50):
47
+ all_extracted_frames.append(frame.copy())
48
+
49
+ # Module A: ViT Image Forensic Analysis
50
+ f_score = analyze_frame(frame)
51
+ frame_scores.append(f_score)
52
+
53
+ # Add this specific second to the UI Scrubber Data
54
+ timeline_data.append({"time": round(timestamp_sec, 2), "ai_score": round(f_score, 3)})
55
+
56
+ if f_score > highest_ai_score:
57
+ highest_ai_score = f_score
58
+ most_suspect_frame_idx = len(all_extracted_frames) - 1
59
+
60
+ # Module B: Microscopic Silicon Noise Analysis
61
+ n_score = compute_noise_entropy_anomaly(frame)
62
+ if n_score is not None:
63
+ noise_scores.append(n_score)
64
+
65
+ # Module C: Diffusion Spectrum Analysis (FFT)
66
+ d_score = compute_diffusion_spectrum_anomaly(frame)
67
+ diffusion_scores.append(d_score)
68
+
69
+ # Module D: Farneback Optical Flow
70
+ if previous_frame is not None:
71
+ m_score = compute_motion_anomaly(previous_frame, frame)
72
+
73
+ if m_score is not None:
74
+ motion_scores.append(m_score)
75
+
76
+ previous_frame = frame
77
+
78
+ if all_extracted_frames:
79
+ # 1. Generate the 2-second GIF Clip
80
+ gif_path = generate_anomaly_gif(all_extracted_frames, most_suspect_frame_idx)
81
+
82
+ # Upload GIF to R2
83
+ class MockFile:
84
+ def __init__(self, f):
85
+ self.file = f
86
+ self.content_type = "image/gif"
87
+
88
+ with open(gif_path, "rb") as hf:
89
+ mock_hf = MockFile(hf)
90
+ import uuid
91
+ safe_gif_name = f"{uuid.uuid4().hex}.gif"
92
+ r2_gif_key = active_storage.save(mock_hf, f"heatmaps/{safe_gif_name}")
93
+
94
+ db_file.heatmap_path = r2_gif_key # Overwriting the static image column with the GIF file!
95
+
96
+ # clean up local gif
97
+ if os.path.exists(gif_path):
98
+ os.remove(gif_path)
99
+
100
+
101
+ # 2. Save the JSON string for the Line Chart Scrubber
102
+ db_file.timeline_data = json.dumps(timeline_data)
103
+
104
+ avg_motion = sum(motion_scores)/len(motion_scores) if motion_scores else None
105
+ avg_noise = sum(noise_scores)/len(noise_scores) if noise_scores else None
106
+ avg_diffusion = sum(diffusion_scores)/len(diffusion_scores) if diffusion_scores else 0.0
107
+
108
+ final_verdict = aggregate_scores(
109
+ frame_scores=frame_scores,
110
+ motion_score=avg_motion,
111
+ noise_score=avg_noise,
112
+ diffusion_score=avg_diffusion,
113
+ metadata_score=md_score
114
+ )
115
+
116
+ db_file.status = "Completed"
117
+ db_file.result = f"{final_verdict['label']} ({final_verdict['probability']*100:.1f}%)"
118
+ db_file.confidence = final_verdict['probability']
119
+ db_file.ai_explanation = final_verdict['explanation']
120
+
121
+ db.commit()
122
+
123
+ elapsed = time.time() - start_time
124
+ logger.info(f"Successfully processed video {file_id} in {elapsed:.2f} seconds.")
125
+ logger.info(f"Verdict: {db_file.result}")
126
+
127
+ except Exception as e:
128
+ logger.error(f"FATAL Pipeline Crash for file_id {file_id}: {str(e)}")
129
+
130
+ try:
131
+ db_file = db.query(File).filter(File.id == file_id).first()
132
+
133
+ if db_file:
134
+ db_file.status = "Failed"
135
+ db_file.result = str(e)
136
+ db.commit()
137
+ except:
138
+ pass
139
+
140
+ finally:
141
+ if 'local_path' in locals() and os.path.exists(local_path) and getattr(active_storage, '__class__').__name__ == "R2StorageProvider":
142
+ os.remove(local_path)
backend/app/worker/tasks.py CHANGED
@@ -97,11 +97,12 @@ def delete_guest_file_task(file_id: int):
97
  try:
98
  from app.models.file_model import File
99
  db_file = db.query(File).filter(File.id == file_id).first()
 
100
  if db_file:
101
- if os.path.exists(db_file.filepath):
102
- os.remove(db_file.filepath)
103
- if db_file.heatmap_path and os.path.exists(db_file.heatmap_path):
104
- os.remove(db_file.heatmap_path)
105
 
106
  db.delete(db_file)
107
  db.commit()
 
97
  try:
98
  from app.models.file_model import File
99
  db_file = db.query(File).filter(File.id == file_id).first()
100
+ from app.core.storage import active_storage
101
  if db_file:
102
+ if db_file.filepath:
103
+ active_storage.delete(db_file.filepath)
104
+ if db_file.heatmap_path:
105
+ active_storage.delete(db_file.heatmap_path)
106
 
107
  db.delete(db_file)
108
  db.commit()
backend/requirements.txt CHANGED
@@ -178,3 +178,5 @@ webencodings==0.5.1
178
  websocket-client==1.9.0
179
  win32_setctime==1.2.0
180
  wrapt==2.1.1
 
 
 
178
  websocket-client==1.9.0
179
  win32_setctime==1.2.0
180
  wrapt==2.1.1
181
+
182
+ boto3==1.34.84
frontend/app/login/page.tsx CHANGED
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
3
  import axios from "axios";
4
  import { ArrowRight, Fingerprint, Mail, KeyRound, User, ArrowLeft } from "lucide-react";
5
  import { useRouter } from "next/navigation";
 
6
 
7
  export default function LoginPage() {
8
  const [mode, setMode] = useState<'login' | 'signup'>('login');
@@ -13,6 +14,7 @@ export default function LoginPage() {
13
  const [error, setError] = useState("");
14
  const [successMessage, setSuccessMessage] = useState("");
15
  const [loading, setLoading] = useState(false);
 
16
  const router = useRouter();
17
 
18
  // OAuth Token Intake
@@ -65,23 +67,22 @@ export default function LoginPage() {
65
  setSuccessMessage("");
66
  setLoading(true);
67
 
68
- // Dynamic Turnstile Logic
69
- let currentToken = sessionStorage.getItem('turnstile_token');
70
- if (!currentToken) {
71
- currentToken = "dummy_swagger_token";
72
- sessionStorage.setItem('turnstile_token', currentToken);
73
  }
74
-
75
  try {
76
  if (mode === 'login') {
77
  const res = await axios.post("http://localhost:8000/auth/login", { identifier, password }, {
78
- headers: { "cf-turnstile-response": currentToken }
79
  });
80
  localStorage.setItem("access_token", res.data.access_token);
81
  window.location.href = "/dashboard";
82
  } else {
83
  const res = await axios.post("http://localhost:8000/users/register", { email, username, password }, {
84
- headers: { "cf-turnstile-response": currentToken }
85
  });
86
  setSuccessMessage(res.data.message || "Account created! Check your email to verify.");
87
  setMode('login');
@@ -203,6 +204,18 @@ export default function LoginPage() {
203
  />
204
  </div>
205
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  <button
207
  type="submit"
208
  disabled={loading}
 
3
  import axios from "axios";
4
  import { ArrowRight, Fingerprint, Mail, KeyRound, User, ArrowLeft } from "lucide-react";
5
  import { useRouter } from "next/navigation";
6
+ import { Turnstile } from "@marsidev/react-turnstile";
7
 
8
  export default function LoginPage() {
9
  const [mode, setMode] = useState<'login' | 'signup'>('login');
 
14
  const [error, setError] = useState("");
15
  const [successMessage, setSuccessMessage] = useState("");
16
  const [loading, setLoading] = useState(false);
17
+ const [turnstileToken, setTurnstileToken] = useState("");
18
  const router = useRouter();
19
 
20
  // OAuth Token Intake
 
67
  setSuccessMessage("");
68
  setLoading(true);
69
 
70
+ if (!turnstileToken) {
71
+ setError("Please complete the security check.");
72
+ setLoading(false);
73
+ return;
 
74
  }
75
+
76
  try {
77
  if (mode === 'login') {
78
  const res = await axios.post("http://localhost:8000/auth/login", { identifier, password }, {
79
+ headers: { "cf-turnstile-response": turnstileToken }
80
  });
81
  localStorage.setItem("access_token", res.data.access_token);
82
  window.location.href = "/dashboard";
83
  } else {
84
  const res = await axios.post("http://localhost:8000/users/register", { email, username, password }, {
85
+ headers: { "cf-turnstile-response": turnstileToken }
86
  });
87
  setSuccessMessage(res.data.message || "Account created! Check your email to verify.");
88
  setMode('login');
 
204
  />
205
  </div>
206
 
207
+ <div className="flex justify-center mt-4">
208
+ <Turnstile
209
+ siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '1x00000000000000000000AA'}
210
+ onSuccess={(token) => setTurnstileToken(token)}
211
+ onError={() => setError("Turnstile security check failed.")}
212
+ onExpire={() => setTurnstileToken("")}
213
+ options={{
214
+ theme: 'dark',
215
+ }}
216
+ />
217
+ </div>
218
+
219
  <button
220
  type="submit"
221
  disabled={loading}
frontend/app/result/[id]/page.tsx CHANGED
@@ -37,6 +37,8 @@ export default function ResultPage() {
37
  const [globalDrag, setGlobalDrag] = useState(false);
38
  const [showAuthModal, setShowAuthModal] = useState(true);
39
  const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
 
 
40
 
41
  useEffect(() => {
42
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -123,23 +125,31 @@ export default function ResultPage() {
123
  if (fileData) {
124
 
125
  if (fileData.filepath) {
126
- const fp = fileData.filepath.replace(/\\/g, '/');
127
- const match = fp.match(/uploads[/].*$/);
128
- if (match) {
129
- mediaUrl = `http://localhost:8000/static/${match[0]}`;
130
  } else {
131
- const filename = fp.split('/').pop();
132
- mediaUrl = filename ? `http://localhost:8000/static/uploads/${filename}` : "";
 
 
 
 
 
 
133
  }
134
  }
135
 
136
  if (fileData.heatmap_path) {
137
- const hp = fileData.heatmap_path.replace(/\\/g, '/');
138
- const match = hp.match(/uploads[/].*$/);
139
- if (match) {
140
- heatmapUrl = `http://localhost:8000/static/${match[0]}`;
141
  } else {
142
- heatmapUrl = `http://localhost:8000/static/${hp.startsWith('/') ? hp.slice(1) : hp}`;
 
 
 
 
 
 
143
  }
144
  }
145
 
@@ -188,6 +198,9 @@ export default function ResultPage() {
188
  }
189
  }
190
 
 
 
 
191
  return (
192
  <main className="min-h-screen bg-[var(--theme-bg)] text-[var(--theme-text)] selection:bg-[var(--theme-text)]/20 selection:text-[var(--theme-text)] pb-32 !cursor-none relative overflow-x-hidden">
193
  <style dangerouslySetInnerHTML={{__html: `
@@ -261,13 +274,30 @@ export default function ResultPage() {
261
  </div>
262
 
263
  <div className="relative aspect-auto rounded-3xl overflow-hidden bg-black/40 border border-theme-border shadow-2xl dash-border flex items-center justify-center min-h-[300px]">
 
 
 
 
 
 
264
  {/* Media Layers */}
265
- {fileData?.type.startsWith("video/") ? (
266
- <video src={mediaUrl} controls className="w-full max-h-[70vh] object-contain rounded-3xl !cursor-none z-10 relative" autoPlay loop muted playsInline />
 
 
 
 
 
 
267
  ) : (
268
  <>
269
  {/* Base Image */}
270
- <img src={mediaUrl} alt="Analyzed Media" className="w-full max-h-[70vh] object-contain !cursor-none z-10 relative pointer-events-none" />
 
 
 
 
 
271
 
272
  {/* Heatmap Overlay (Clipped) */}
273
  {heatmapUrl && (
@@ -276,6 +306,7 @@ export default function ResultPage() {
276
  alt="Heatmap/Noise Pattern"
277
  className="absolute inset-0 w-full h-full object-contain !cursor-none z-20 pointer-events-none"
278
  style={{ clipPath: `inset(0 ${100 - sliderValue}% 0 0)` }}
 
279
  />
280
  )}
281
 
 
37
  const [globalDrag, setGlobalDrag] = useState(false);
38
  const [showAuthModal, setShowAuthModal] = useState(true);
39
  const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
40
+ const [mediaLoaded, setMediaLoaded] = useState(false);
41
+ const [heatmapLoaded, setHeatmapLoaded] = useState(false);
42
 
43
  useEffect(() => {
44
  const handleKeyDown = (e: KeyboardEvent) => {
 
125
  if (fileData) {
126
 
127
  if (fileData.filepath) {
128
+ if (fileData.filepath.startsWith('http://') || fileData.filepath.startsWith('https://')) {
129
+ mediaUrl = fileData.filepath;
 
 
130
  } else {
131
+ const fp = fileData.filepath.replace(/\\/g, '/');
132
+ const match = fp.match(/uploads[/].*$/);
133
+ if (match) {
134
+ mediaUrl = `http://localhost:8000/static/${match[0]}`;
135
+ } else {
136
+ const filename = fp.split('/').pop();
137
+ mediaUrl = filename ? `http://localhost:8000/static/uploads/${filename}` : "";
138
+ }
139
  }
140
  }
141
 
142
  if (fileData.heatmap_path) {
143
+ if (fileData.heatmap_path.startsWith('http://') || fileData.heatmap_path.startsWith('https://')) {
144
+ heatmapUrl = fileData.heatmap_path;
 
 
145
  } else {
146
+ const hp = fileData.heatmap_path.replace(/\\/g, '/');
147
+ const match = hp.match(/uploads[/].*$/);
148
+ if (match) {
149
+ heatmapUrl = `http://localhost:8000/static/${match[0]}`;
150
+ } else {
151
+ heatmapUrl = `http://localhost:8000/static/${hp.startsWith('/') ? hp.slice(1) : hp}`;
152
+ }
153
  }
154
  }
155
 
 
198
  }
199
  }
200
 
201
+ const isVideo = fileData?.type?.startsWith("video/");
202
+ const allLoaded = isVideo ? mediaLoaded : (mediaLoaded && (!heatmapUrl || heatmapLoaded));
203
+
204
  return (
205
  <main className="min-h-screen bg-[var(--theme-bg)] text-[var(--theme-text)] selection:bg-[var(--theme-text)]/20 selection:text-[var(--theme-text)] pb-32 !cursor-none relative overflow-x-hidden">
206
  <style dangerouslySetInnerHTML={{__html: `
 
274
  </div>
275
 
276
  <div className="relative aspect-auto rounded-3xl overflow-hidden bg-black/40 border border-theme-border shadow-2xl dash-border flex items-center justify-center min-h-[300px]">
277
+ {/* Skeleton Loader */}
278
+ <div className={`absolute inset-0 bg-[var(--theme-bg)] z-50 transition-opacity duration-700 pointer-events-none flex items-center justify-center ${allLoaded ? 'opacity-0' : 'opacity-100'}`}>
279
+ <div className="absolute inset-0 bg-[var(--theme-text)]/5 animate-pulse"></div>
280
+ <div className="w-12 h-12 border-4 border-[var(--theme-border)] border-t-[var(--theme-text)] rounded-full animate-spin shadow-[0_0_15px_rgba(253,232,214,0.2)]"></div>
281
+ </div>
282
+
283
  {/* Media Layers */}
284
+ {isVideo ? (
285
+ <video
286
+ src={mediaUrl}
287
+ controls
288
+ className="w-full max-h-[70vh] object-contain rounded-3xl !cursor-none z-10 relative"
289
+ autoPlay loop muted playsInline
290
+ onLoadedData={() => setMediaLoaded(true)}
291
+ />
292
  ) : (
293
  <>
294
  {/* Base Image */}
295
+ <img
296
+ src={mediaUrl}
297
+ alt="Analyzed Media"
298
+ className="w-full max-h-[70vh] object-contain !cursor-none z-10 relative pointer-events-none"
299
+ onLoad={() => setMediaLoaded(true)}
300
+ />
301
 
302
  {/* Heatmap Overlay (Clipped) */}
303
  {heatmapUrl && (
 
306
  alt="Heatmap/Noise Pattern"
307
  className="absolute inset-0 w-full h-full object-contain !cursor-none z-20 pointer-events-none"
308
  style={{ clipPath: `inset(0 ${100 - sliderValue}% 0 0)` }}
309
+ onLoad={() => setHeatmapLoaded(true)}
310
  />
311
  )}
312
 
frontend/components/upload/UploadZone.tsx CHANGED
@@ -49,8 +49,7 @@ export default function UploadZone({ autoAnalyze = false }: { autoAnalyze?: bool
49
  try {
50
  const token = localStorage.getItem("access_token");
51
  const headers: Record<string, string> = {
52
- "Content-Type": "multipart/form-data",
53
- "cf-turnstile-response": "dummy_swagger_token"
54
  };
55
  if (token) headers["Authorization"] = `Bearer ${token}`;
56
 
 
49
  try {
50
  const token = localStorage.getItem("access_token");
51
  const headers: Record<string, string> = {
52
+ "Content-Type": "multipart/form-data"
 
53
  };
54
  if (token) headers["Authorization"] = `Bearer ${token}`;
55
 
frontend/contexts/AuthContext.tsx CHANGED
@@ -29,7 +29,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
29
  const [loading, setLoading] = useState(true);
30
 
31
  const router = useRouter();
32
- const pathname = usePathname();
33
 
34
  useEffect(() => {
35
  // Only check if we are not on a public unprotected path unless it's specifically standard.
@@ -45,14 +44,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
45
  setIsAuthenticated(false);
46
  setUser(null);
47
  // Explicitly guard the dashboard
48
- if (pathname?.includes('/dashboard')) {
49
  router.push('/login');
50
  }
51
  })
52
  .finally(() => {
53
  setLoading(false);
54
  });
55
- }, [pathname, router]);
56
 
57
  const logout = () => {
58
  localStorage.removeItem("access_token");
 
29
  const [loading, setLoading] = useState(true);
30
 
31
  const router = useRouter();
 
32
 
33
  useEffect(() => {
34
  // Only check if we are not on a public unprotected path unless it's specifically standard.
 
44
  setIsAuthenticated(false);
45
  setUser(null);
46
  // Explicitly guard the dashboard
47
+ if (window.location.pathname.includes('/dashboard')) {
48
  router.push('/login');
49
  }
50
  })
51
  .finally(() => {
52
  setLoading(false);
53
  });
54
+ }, [router]);
55
 
56
  const logout = () => {
57
  localStorage.removeItem("access_token");