Spaces:
Running
Running
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 +3 -4
- backend/app/core/config.py +5 -0
- backend/app/core/processor.py +28 -6
- backend/app/core/storage.py +118 -32
- backend/app/db/database.py +13 -2
- backend/app/security/turnstile.py +1 -1
- backend/app/services/file_delete_service.py +4 -1
- backend/app/services/video_processor.py +142 -116
- backend/app/worker/tasks.py +5 -4
- backend/requirements.txt +2 -0
- frontend/app/login/page.tsx +21 -8
- frontend/app/result/[id]/page.tsx +45 -14
- frontend/components/upload/UploadZone.tsx +1 -2
- frontend/contexts/AuthContext.tsx +2 -3
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 |
-
|
|
|
|
|
|
|
| 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(
|
| 34 |
-
features = extract_features(
|
| 35 |
label, prob = predict_ai(features["frequency_score"], features["cnn_score"])
|
| 36 |
-
attribution_data = generate_attribution(
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
from
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
# Module
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 102 |
-
|
| 103 |
-
if db_file.heatmap_path
|
| 104 |
-
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 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":
|
| 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":
|
| 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 |
-
|
| 127 |
-
|
| 128 |
-
if (match) {
|
| 129 |
-
mediaUrl = `http://localhost:8000/static/${match[0]}`;
|
| 130 |
} else {
|
| 131 |
-
const
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
}
|
| 135 |
|
| 136 |
if (fileData.heatmap_path) {
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
if (match) {
|
| 140 |
-
heatmapUrl = `http://localhost:8000/static/${match[0]}`;
|
| 141 |
} else {
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 266 |
-
<video
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
) : (
|
| 268 |
<>
|
| 269 |
{/* Base Image */}
|
| 270 |
-
<img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 49 |
router.push('/login');
|
| 50 |
}
|
| 51 |
})
|
| 52 |
.finally(() => {
|
| 53 |
setLoading(false);
|
| 54 |
});
|
| 55 |
-
}, [
|
| 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");
|