Spaces:
Runtime error
Runtime error
Sync from GitHub via hub-sync
Browse files- api/v1/auth.py +14 -5
- api/v1/report.py +1 -1
- api/v1/stats.py +3 -1
- config.py +16 -1
- db/database.py +6 -8
- deepshield.db-shm +0 -0
- deepshield.db-wal +0 -0
- logs/deepshield.log +9 -0
- main.py +19 -2
- schemas/auth.py +17 -1
- services/auth_service.py +3 -1
- services/rate_limit.py +2 -0
- utils/scoring.py +8 -0
api/v1/auth.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 4 |
from loguru import logger
|
| 5 |
from sqlalchemy.exc import IntegrityError
|
| 6 |
from sqlalchemy.orm import Session
|
|
@@ -11,6 +11,7 @@ from db.database import get_db
|
|
| 11 |
from db.models import User
|
| 12 |
from schemas.auth import LoginBody, RegisterBody, TokenResponse, UserOut
|
| 13 |
from services.auth_service import authenticate, create_access_token, register_user
|
|
|
|
| 14 |
|
| 15 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 16 |
|
|
@@ -23,23 +24,31 @@ def _token_response(user: User) -> TokenResponse:
|
|
| 23 |
)
|
| 24 |
|
| 25 |
|
|
|
|
| 26 |
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
| 27 |
-
def register(body: RegisterBody, db: Session = Depends(get_db)) -> TokenResponse:
|
| 28 |
try:
|
| 29 |
user = register_user(db, body.email, body.password, body.name)
|
| 30 |
except IntegrityError:
|
| 31 |
db.rollback()
|
|
|
|
|
|
|
| 32 |
raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
|
| 33 |
-
|
|
|
|
| 34 |
return _token_response(user)
|
| 35 |
|
| 36 |
|
|
|
|
| 37 |
@router.post("/login", response_model=TokenResponse)
|
| 38 |
-
def login(body: LoginBody, db: Session = Depends(get_db)) -> TokenResponse:
|
| 39 |
user = authenticate(db, body.email, body.password)
|
| 40 |
if not user:
|
|
|
|
|
|
|
| 41 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
|
| 42 |
-
|
|
|
|
| 43 |
return _token_response(user)
|
| 44 |
|
| 45 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 4 |
from loguru import logger
|
| 5 |
from sqlalchemy.exc import IntegrityError
|
| 6 |
from sqlalchemy.orm import Session
|
|
|
|
| 11 |
from db.models import User
|
| 12 |
from schemas.auth import LoginBody, RegisterBody, TokenResponse, UserOut
|
| 13 |
from services.auth_service import authenticate, create_access_token, register_user
|
| 14 |
+
from services.rate_limit import ANON_AUTH_LOGIN, ANON_AUTH_REGISTER, limiter
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 17 |
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
|
| 27 |
+
@limiter.limit(ANON_AUTH_REGISTER)
|
| 28 |
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
| 29 |
+
def register(body: RegisterBody, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
|
| 30 |
try:
|
| 31 |
user = register_user(db, body.email, body.password, body.name)
|
| 32 |
except IntegrityError:
|
| 33 |
db.rollback()
|
| 34 |
+
client_host = request.client.host if request.client else "unknown"
|
| 35 |
+
logger.warning(f"Registration rejected email={body.email} ip={client_host}")
|
| 36 |
raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
|
| 37 |
+
client_host = request.client.host if request.client else "unknown"
|
| 38 |
+
logger.info(f"Registered user id={user.id} email={user.email} ip={client_host}")
|
| 39 |
return _token_response(user)
|
| 40 |
|
| 41 |
|
| 42 |
+
@limiter.limit(ANON_AUTH_LOGIN)
|
| 43 |
@router.post("/login", response_model=TokenResponse)
|
| 44 |
+
def login(body: LoginBody, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
|
| 45 |
user = authenticate(db, body.email, body.password)
|
| 46 |
if not user:
|
| 47 |
+
client_host = request.client.host if request.client else "unknown"
|
| 48 |
+
logger.warning(f"Login failed email={body.email} ip={client_host}")
|
| 49 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
|
| 50 |
+
client_host = request.client.host if request.client else "unknown"
|
| 51 |
+
logger.info(f"Login user id={user.id} email={user.email} ip={client_host}")
|
| 52 |
return _token_response(user)
|
| 53 |
|
| 54 |
|
api/v1/report.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 6 |
from fastapi.responses import FileResponse
|
| 7 |
from loguru import logger
|
| 8 |
from sqlalchemy.orm import Session
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
| 6 |
from fastapi.responses import FileResponse
|
| 7 |
from loguru import logger
|
| 8 |
from sqlalchemy.orm import Session
|
api/v1/stats.py
CHANGED
|
@@ -3,13 +3,15 @@ from fastapi import APIRouter, Depends
|
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from sqlalchemy import func
|
| 5 |
|
|
|
|
| 6 |
from db.database import get_db
|
| 7 |
from db.models import AnalysisRecord
|
|
|
|
| 8 |
|
| 9 |
router = APIRouter(prefix="/stats", tags=["stats"])
|
| 10 |
|
| 11 |
@router.get("/recent")
|
| 12 |
-
def get_recent_stats(db: Session = Depends(get_db)):
|
| 13 |
"""Phase 20.4 — Live Engagement Counter."""
|
| 14 |
twenty_four_hours_ago = datetime.utcnow() - timedelta(hours=24)
|
| 15 |
count = db.query(func.count(AnalysisRecord.id)).filter(AnalysisRecord.created_at >= twenty_four_hours_ago).scalar()
|
|
|
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from sqlalchemy import func
|
| 5 |
|
| 6 |
+
from api.deps import get_current_user
|
| 7 |
from db.database import get_db
|
| 8 |
from db.models import AnalysisRecord
|
| 9 |
+
from db.models import User
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/stats", tags=["stats"])
|
| 12 |
|
| 13 |
@router.get("/recent")
|
| 14 |
+
def get_recent_stats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 15 |
"""Phase 20.4 — Live Engagement Counter."""
|
| 16 |
twenty_four_hours_ago = datetime.utcnow() - timedelta(hours=24)
|
| 17 |
count = db.query(func.count(AnalysisRecord.id)).filter(AnalysisRecord.created_at >= twenty_four_hours_ago).scalar()
|
config.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import json
|
|
|
|
| 2 |
from urllib.parse import parse_qsl, urlencode
|
| 3 |
from typing import Any
|
| 4 |
from pydantic import field_validator, model_validator
|
|
@@ -193,10 +194,24 @@ class Settings(BaseSettings):
|
|
| 193 |
EXIFTOOL_PATH: str = "" # full path to ExifTool binary; empty = metadata write disabled
|
| 194 |
|
| 195 |
# Auth
|
| 196 |
-
JWT_SECRET_KEY: str = "
|
|
|
|
| 197 |
JWT_ALGORITHM: str = "HS256"
|
| 198 |
JWT_EXPIRATION_MINUTES: int = 1440
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
@field_validator("ALLOWED_IMAGE_TYPES", mode="before")
|
| 201 |
@classmethod
|
| 202 |
def assemble_allowed_image_types(cls, v: Any) -> list[str]:
|
|
|
|
| 1 |
import json
|
| 2 |
+
import secrets
|
| 3 |
from urllib.parse import parse_qsl, urlencode
|
| 4 |
from typing import Any
|
| 5 |
from pydantic import field_validator, model_validator
|
|
|
|
| 194 |
EXIFTOOL_PATH: str = "" # full path to ExifTool binary; empty = metadata write disabled
|
| 195 |
|
| 196 |
# Auth
|
| 197 |
+
JWT_SECRET_KEY: str = ""
|
| 198 |
+
JWT_SECRET_KEY_GENERATED: bool = False
|
| 199 |
JWT_ALGORITHM: str = "HS256"
|
| 200 |
JWT_EXPIRATION_MINUTES: int = 1440
|
| 201 |
|
| 202 |
+
@model_validator(mode="after")
|
| 203 |
+
def ensure_jwt_secret(self):
|
| 204 |
+
if not self.JWT_SECRET_KEY:
|
| 205 |
+
if self.DEBUG:
|
| 206 |
+
self.JWT_SECRET_KEY = secrets.token_urlsafe(48)
|
| 207 |
+
self.JWT_SECRET_KEY_GENERATED = True
|
| 208 |
+
else:
|
| 209 |
+
self.JWT_SECRET_KEY = secrets.token_urlsafe(48)
|
| 210 |
+
self.JWT_SECRET_KEY_GENERATED = True
|
| 211 |
+
else:
|
| 212 |
+
self.JWT_SECRET_KEY_GENERATED = False
|
| 213 |
+
return self
|
| 214 |
+
|
| 215 |
@field_validator("ALLOWED_IMAGE_TYPES", mode="before")
|
| 216 |
@classmethod
|
| 217 |
def assemble_allowed_image_types(cls, v: Any) -> list[str]:
|
db/database.py
CHANGED
|
@@ -47,15 +47,13 @@ def init_db():
|
|
| 47 |
insp = inspect(engine)
|
| 48 |
if "analyses" in insp.get_table_names():
|
| 49 |
existing = {c["name"] for c in insp.get_columns("analyses")}
|
| 50 |
-
additions = {
|
| 51 |
-
"media_hash": "VARCHAR(64)",
|
| 52 |
-
"media_path": "VARCHAR(512)",
|
| 53 |
-
"thumbnail_url": "VARCHAR(512)",
|
| 54 |
-
}
|
| 55 |
with engine.begin() as conn:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 59 |
# Indices (CREATE INDEX IF NOT EXISTS is SQLite+Postgres safe)
|
| 60 |
for ddl in (
|
| 61 |
"CREATE INDEX IF NOT EXISTS ix_analyses_media_hash ON analyses (media_hash)",
|
|
|
|
| 47 |
insp = inspect(engine)
|
| 48 |
if "analyses" in insp.get_table_names():
|
| 49 |
existing = {c["name"] for c in insp.get_columns("analyses")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
with engine.begin() as conn:
|
| 51 |
+
if "media_hash" not in existing:
|
| 52 |
+
conn.execute(text("ALTER TABLE analyses ADD COLUMN media_hash VARCHAR(64)"))
|
| 53 |
+
if "media_path" not in existing:
|
| 54 |
+
conn.execute(text("ALTER TABLE analyses ADD COLUMN media_path VARCHAR(512)"))
|
| 55 |
+
if "thumbnail_url" not in existing:
|
| 56 |
+
conn.execute(text("ALTER TABLE analyses ADD COLUMN thumbnail_url VARCHAR(512)"))
|
| 57 |
# Indices (CREATE INDEX IF NOT EXISTS is SQLite+Postgres safe)
|
| 58 |
for ddl in (
|
| 59 |
"CREATE INDEX IF NOT EXISTS ix_analyses_media_hash ON analyses (media_hash)",
|
deepshield.db-shm
DELETED
|
Binary file (32.8 kB)
|
|
|
deepshield.db-wal
DELETED
|
Binary file (86.6 kB)
|
|
|
logs/deepshield.log
CHANGED
|
@@ -949,3 +949,12 @@ TypeError: int() argument must be a string, a bytes-like object or a real number
|
|
| 949 |
2026-04-26 22:09:45.469 | INFO | services.report_service:generate_report:120 - Report generated id=43 path=temp_reports\deepshield_43_262befa5.pdf size=15602B
|
| 950 |
2026-04-26 23:15:58.262 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
|
| 951 |
2026-04-27 01:22:14.691 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 949 |
2026-04-26 22:09:45.469 | INFO | services.report_service:generate_report:120 - Report generated id=43 path=temp_reports\deepshield_43_262befa5.pdf size=15602B
|
| 950 |
2026-04-26 23:15:58.262 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
|
| 951 |
2026-04-27 01:22:14.691 | INFO | services.report_service:cleanup_expired:149 - Cleaned up 2 expired reports
|
| 952 |
+
2026-04-28 21:11:45.835 | INFO | main:lifespan:108 - Starting DeepShield backend
|
| 953 |
+
2026-04-28 21:11:45.871 | INFO | main:lifespan:110 - Database initialized
|
| 954 |
+
2026-04-28 21:11:45.871 | INFO | models.model_loader:load_image_model:48 - Loading image model: prithivMLmods/Deep-Fake-Detector-v2-Model
|
| 955 |
+
2026-04-28 21:11:51.313 | INFO | models.model_loader:load_image_model:56 - Image model loaded
|
| 956 |
+
2026-04-28 21:34:27.293 | INFO | main:lifespan:118 - Shutting down DeepShield backend
|
| 957 |
+
2026-04-28 21:34:41.654 | INFO | main:lifespan:108 - Starting DeepShield backend
|
| 958 |
+
2026-04-28 21:34:41.668 | INFO | main:lifespan:110 - Database initialized
|
| 959 |
+
2026-04-28 21:34:41.668 | INFO | models.model_loader:load_image_model:48 - Loading image model: prithivMLmods/Deep-Fake-Detector-v2-Model
|
| 960 |
+
2026-04-28 21:34:44.266 | INFO | models.model_loader:load_image_model:56 - Image model loaded
|
main.py
CHANGED
|
@@ -11,7 +11,7 @@ from slowapi import _rate_limit_exceeded_handler
|
|
| 11 |
from slowapi.errors import RateLimitExceeded
|
| 12 |
|
| 13 |
from starlette.middleware.base import BaseHTTPMiddleware
|
| 14 |
-
from starlette.responses import JSONResponse
|
| 15 |
|
| 16 |
from api.router import api_router
|
| 17 |
from config import settings
|
|
@@ -39,6 +39,21 @@ class ContentLengthLimitMiddleware(BaseHTTPMiddleware):
|
|
| 39 |
return await call_next(request)
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# === Phase 15.3 — JWT / CORS / logging hardening ===
|
| 43 |
|
| 44 |
_DEFAULT_JWT_SECRET = "change-me-in-production"
|
|
@@ -46,7 +61,7 @@ _DEFAULT_JWT_SECRET = "change-me-in-production"
|
|
| 46 |
|
| 47 |
def _enforce_production_hardening() -> None:
|
| 48 |
"""Refuse to start in production with unsafe defaults (Phase 15.3)."""
|
| 49 |
-
if settings.JWT_SECRET_KEY == _DEFAULT_JWT_SECRET or not settings.JWT_SECRET_KEY:
|
| 50 |
example = secrets.token_urlsafe(48)
|
| 51 |
if settings.DEBUG:
|
| 52 |
logger.warning(
|
|
@@ -131,6 +146,8 @@ app.state.limiter = limiter
|
|
| 131 |
|
| 132 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 133 |
app.add_middleware(RateLimitContextMiddleware)
|
|
|
|
|
|
|
| 134 |
# Phase 15.3 — reject oversized uploads before reading body
|
| 135 |
app.add_middleware(ContentLengthLimitMiddleware, max_bytes=settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024)
|
| 136 |
|
|
|
|
| 11 |
from slowapi.errors import RateLimitExceeded
|
| 12 |
|
| 13 |
from starlette.middleware.base import BaseHTTPMiddleware
|
| 14 |
+
from starlette.responses import JSONResponse, RedirectResponse
|
| 15 |
|
| 16 |
from api.router import api_router
|
| 17 |
from config import settings
|
|
|
|
| 39 |
return await call_next(request)
|
| 40 |
|
| 41 |
|
| 42 |
+
class HTTPSRedirectAndHSTSMiddleware(BaseHTTPMiddleware):
|
| 43 |
+
async def dispatch(self, request, call_next):
|
| 44 |
+
if not settings.DEBUG:
|
| 45 |
+
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
|
| 46 |
+
host = request.headers.get("host", "").split(":", 1)[0].lower()
|
| 47 |
+
if forwarded_proto != "https" and request.url.scheme != "https" and host not in {"127.0.0.1", "localhost", ""}:
|
| 48 |
+
https_url = request.url.replace(scheme="https")
|
| 49 |
+
return RedirectResponse(str(https_url), status_code=308)
|
| 50 |
+
|
| 51 |
+
response = await call_next(request)
|
| 52 |
+
if not settings.DEBUG:
|
| 53 |
+
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
| 54 |
+
return response
|
| 55 |
+
|
| 56 |
+
|
| 57 |
# === Phase 15.3 — JWT / CORS / logging hardening ===
|
| 58 |
|
| 59 |
_DEFAULT_JWT_SECRET = "change-me-in-production"
|
|
|
|
| 61 |
|
| 62 |
def _enforce_production_hardening() -> None:
|
| 63 |
"""Refuse to start in production with unsafe defaults (Phase 15.3)."""
|
| 64 |
+
if settings.JWT_SECRET_KEY == _DEFAULT_JWT_SECRET or not settings.JWT_SECRET_KEY or settings.JWT_SECRET_KEY_GENERATED:
|
| 65 |
example = secrets.token_urlsafe(48)
|
| 66 |
if settings.DEBUG:
|
| 67 |
logger.warning(
|
|
|
|
| 146 |
|
| 147 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 148 |
app.add_middleware(RateLimitContextMiddleware)
|
| 149 |
+
# Phase 15.3 — enforce HTTPS in production and add HSTS
|
| 150 |
+
app.add_middleware(HTTPSRedirectAndHSTSMiddleware)
|
| 151 |
# Phase 15.3 — reject oversized uploads before reading body
|
| 152 |
app.add_middleware(ContentLengthLimitMiddleware, max_bytes=settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024)
|
| 153 |
|
schemas/auth.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
-
from pydantic import BaseModel, EmailStr, Field
|
| 6 |
|
| 7 |
|
| 8 |
class RegisterBody(BaseModel):
|
|
@@ -10,6 +11,21 @@ class RegisterBody(BaseModel):
|
|
| 10 |
password: str = Field(min_length=6, max_length=128)
|
| 11 |
name: str | None = Field(default=None, max_length=255)
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class LoginBody(BaseModel):
|
| 15 |
email: EmailStr
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import re
|
| 4 |
from datetime import datetime
|
| 5 |
|
| 6 |
+
from pydantic import BaseModel, EmailStr, Field, field_validator
|
| 7 |
|
| 8 |
|
| 9 |
class RegisterBody(BaseModel):
|
|
|
|
| 11 |
password: str = Field(min_length=6, max_length=128)
|
| 12 |
name: str | None = Field(default=None, max_length=255)
|
| 13 |
|
| 14 |
+
@field_validator("password")
|
| 15 |
+
@classmethod
|
| 16 |
+
def validate_password_strength(cls, value: str) -> str:
|
| 17 |
+
if len(value) < 8:
|
| 18 |
+
raise ValueError("Password must be at least 8 characters long")
|
| 19 |
+
if not re.search(r"[a-z]", value):
|
| 20 |
+
raise ValueError("Password must include a lowercase letter")
|
| 21 |
+
if not re.search(r"[A-Z]", value):
|
| 22 |
+
raise ValueError("Password must include an uppercase letter")
|
| 23 |
+
if not re.search(r"\d", value):
|
| 24 |
+
raise ValueError("Password must include a digit")
|
| 25 |
+
if not re.search(r"[^A-Za-z0-9]", value):
|
| 26 |
+
raise ValueError("Password must include a symbol")
|
| 27 |
+
return value
|
| 28 |
+
|
| 29 |
|
| 30 |
class LoginBody(BaseModel):
|
| 31 |
email: EmailStr
|
services/auth_service.py
CHANGED
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
| 5 |
|
| 6 |
import bcrypt
|
| 7 |
from jose import JWTError, jwt
|
|
|
|
| 8 |
from sqlalchemy.orm import Session
|
| 9 |
|
| 10 |
from config import settings
|
|
@@ -24,7 +25,8 @@ def hash_password(plain: str) -> str:
|
|
| 24 |
def verify_password(plain: str, hashed: str) -> bool:
|
| 25 |
try:
|
| 26 |
return bcrypt.checkpw(_encode_pw(plain), hashed.encode("utf-8"))
|
| 27 |
-
except Exception:
|
|
|
|
| 28 |
return False
|
| 29 |
|
| 30 |
|
|
|
|
| 5 |
|
| 6 |
import bcrypt
|
| 7 |
from jose import JWTError, jwt
|
| 8 |
+
from loguru import logger
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from config import settings
|
|
|
|
| 25 |
def verify_password(plain: str, hashed: str) -> bool:
|
| 26 |
try:
|
| 27 |
return bcrypt.checkpw(_encode_pw(plain), hashed.encode("utf-8"))
|
| 28 |
+
except Exception as exc:
|
| 29 |
+
logger.warning(f"Password verification failed due to malformed hash: {exc}")
|
| 30 |
return False
|
| 31 |
|
| 32 |
|
services/rate_limit.py
CHANGED
|
@@ -80,6 +80,8 @@ ANON_ANALYZE = "5/hour"
|
|
| 80 |
AUTH_ANALYZE = "50/hour"
|
| 81 |
ANON_REPORT = "2/hour"
|
| 82 |
AUTH_REPORT = "20/hour"
|
|
|
|
|
|
|
| 83 |
|
| 84 |
limiter = Limiter(
|
| 85 |
key_func=request_key,
|
|
|
|
| 80 |
AUTH_ANALYZE = "50/hour"
|
| 81 |
ANON_REPORT = "2/hour"
|
| 82 |
AUTH_REPORT = "20/hour"
|
| 83 |
+
ANON_AUTH_REGISTER = "5/hour"
|
| 84 |
+
ANON_AUTH_LOGIN = "10/minute"
|
| 85 |
|
| 86 |
limiter = Limiter(
|
| 87 |
key_func=request_key,
|
utils/scoring.py
CHANGED
|
@@ -11,6 +11,12 @@ TRUST_SCALE = [
|
|
| 11 |
]
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def compute_authenticity_score(fake_probability: float, label: str = "") -> int:
|
| 15 |
"""Map a fake probability [0.0, 1.0] to a 0-100 authenticity score.
|
| 16 |
|
|
@@ -62,8 +68,10 @@ def compute_video_authenticity_score(
|
|
| 62 |
visual_score = (1.0 - float(mean_suspicious_prob)) * 100.0
|
| 63 |
temporal_sc = float(temporal_score) if temporal_score is not None else visual_score
|
| 64 |
if has_audio and audio_authenticity_score is not None:
|
|
|
|
| 65 |
combined = 0.50 * visual_score + 0.30 * temporal_sc + 0.20 * float(audio_authenticity_score)
|
| 66 |
else:
|
|
|
|
| 67 |
combined = 0.70 * visual_score + 0.30 * temporal_sc
|
| 68 |
score = int(round(max(0.0, min(100.0, combined))))
|
| 69 |
label, severity = get_verdict_label(score)
|
|
|
|
| 11 |
]
|
| 12 |
|
| 13 |
|
| 14 |
+
def _validate_weight_total(weights: list[float], context: str) -> None:
|
| 15 |
+
total = sum(weights)
|
| 16 |
+
if total > 1.000001:
|
| 17 |
+
raise ValueError(f"{context} weights must not sum above 1.0 (got {total:.3f})")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
def compute_authenticity_score(fake_probability: float, label: str = "") -> int:
|
| 21 |
"""Map a fake probability [0.0, 1.0] to a 0-100 authenticity score.
|
| 22 |
|
|
|
|
| 68 |
visual_score = (1.0 - float(mean_suspicious_prob)) * 100.0
|
| 69 |
temporal_sc = float(temporal_score) if temporal_score is not None else visual_score
|
| 70 |
if has_audio and audio_authenticity_score is not None:
|
| 71 |
+
_validate_weight_total([0.50, 0.30, 0.20], "video audio+temporal fusion")
|
| 72 |
combined = 0.50 * visual_score + 0.30 * temporal_sc + 0.20 * float(audio_authenticity_score)
|
| 73 |
else:
|
| 74 |
+
_validate_weight_total([0.70, 0.30], "video visual+temporal fusion")
|
| 75 |
combined = 0.70 * visual_score + 0.30 * temporal_sc
|
| 76 |
score = int(round(max(0.0, min(100.0, combined))))
|
| 77 |
label, severity = get_verdict_label(score)
|