diff --git a/.env.example b/.env.example index c6122c0..96c7e02 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,25 @@ POSTGRES_DB=arkhamdb # Only set if using an external Redis instance: # REDIS_URL=redis://your-redis-host:6379 +# ----------------------------------------------------------------------------- +# Media Forensics - Reverse Image Search API Keys (Optional) +# ----------------------------------------------------------------------------- +# These API keys enable automated reverse image search functionality. +# Without them, the Media Forensics shard still provides clickable URLs +# for manual reverse image searches on Google, TinEye, Yandex, and Bing. + +# TinEye API - Commercial reverse image search +# Sign up at: https://tineye.com/developers +# TINEYE_API_KEY=your-tineye-api-key + +# Google Cloud Vision API - Image analysis and web detection +# Enable at: https://console.cloud.google.com/apis/library/vision.googleapis.com +# GOOGLE_VISION_API_KEY=your-google-vision-api-key + +# SerpAPI - Google Images search results (has free tier) +# Sign up at: https://serpapi.com/ +# SERPAPI_KEY=your-serpapi-key + # ----------------------------------------------------------------------------- # Production HTTPS (Traefik) - Optional # ----------------------------------------------------------------------------- diff --git a/Dockerfile b/Dockerfile index a12af6e..104812d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,6 +78,7 @@ COPY packages/arkham-shard-settings/ ./packages/arkham-shard-settings/ COPY packages/arkham-shard-summary/ ./packages/arkham-shard-summary/ COPY packages/arkham-shard-templates/ ./packages/arkham-shard-templates/ COPY packages/arkham-shard-timeline/ ./packages/arkham-shard-timeline/ +COPY packages/arkham-shard-media-forensics/ ./packages/arkham-shard-media-forensics/ # Install CPU-only PyTorch BEFORE shards to avoid downloading ~3GB of CUDA libraries # This must come before sentence-transformers (used by arkham-shard-embed) @@ -147,6 +148,7 @@ COPY packages/arkham-shard-settings/shard.yaml /app/manifests/settings.yaml COPY packages/arkham-shard-summary/shard.yaml /app/manifests/summary.yaml COPY packages/arkham-shard-templates/shard.yaml /app/manifests/templates.yaml COPY packages/arkham-shard-timeline/shard.yaml /app/manifests/timeline.yaml +COPY packages/arkham-shard-media-forensics/shard.yaml /app/manifests/media-forensics.yaml WORKDIR /app diff --git a/README.md b/README.md index 474d210..3a6fa42 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ Hybrid semantic/keyword search with built-in regex patterns for PII, financial d ![Search](docs/Screens/regex.png) +### Media Forensics +Image authenticity analysis with EXIF extraction, Error Level Analysis (ELA), perceptual hashing, and reverse image search integration. + +![Media Forensics](docs/Screens/mediaforensics.png) + --- @@ -113,7 +118,7 @@ SHATTERED uses the **Voltron** architectural philosophy: a modular, plug-and-pla +--------------------+--------------------+ | | | | | +----v----+ +--v--+ +-----v-----+ +--v--+ +---v---+ - |Dashboard| | ACH | | Search | |Graph| |Timeline| <-- SHARDS (25) + |Dashboard| | ACH | | Search | |Graph| |Timeline| <-- SHARDS (26) +---------+ +-----+ +-----------+ +-----+ +--------+ ``` @@ -268,7 +273,7 @@ The Frame provides 17 core services available to all shards: |-------|-------------|--------------| | **Search** | Document search | Semantic, keyword, hybrid search, facets, suggestions | -### Analysis (8 shards) +### Analysis (9 shards) | Shard | Description | Key Features | |-------|-------------|--------------| @@ -280,6 +285,7 @@ The Frame provides 17 core services available to all shards: | **Patterns** | Pattern recognition | Recurring, behavioral, temporal, correlation patterns | | **Provenance** | Evidence chains | Data lineage, audit trails, artifact verification | | **Summary** | Auto-summarization | Multiple summary types, batch processing, auto-summarize on ingest | +| **Media Forensics** | Image authenticity analysis | EXIF extraction, ELA analysis, perceptual hashing, C2PA verification, reverse image search | ### Visualization (2 shards) @@ -332,6 +338,7 @@ The Frame provides 17 core services available to all shards: | **NER** | spaCy (en_core_web_sm/lg/trf) | | **OCR** | PaddleOCR, Vision LLM (Qwen-VL local, or cloud GPT-4o/Claude) | | **Embeddings** | sentence-transformers, OpenAI | +| **Reverse Image Search** | TinEye, Google Vision, SerpAPI (optional APIs for Media Forensics) | --- @@ -782,7 +789,7 @@ Each shard has its own README with API documentation, events, and usage examples | Metric | Value | |--------|-------| | **Lines of Code** | ~217,000 | -| **Total Packages** | 26 (25 shards + shell) | +| **Total Packages** | 27 (26 shards + shell) | | **Frame Services** | 17 | | **API Endpoints** | 400+ | | **Graph Visualization Modes** | 10+ | diff --git a/docker-compose.yml b/docker-compose.yml index a13fb15..401ae0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: # Recommended: BAAI/bge-m3 (1024 dims, ~2.2GB, high quality) # Lightweight: all-MiniLM-L6-v2 (384 dims, ~80MB, fast) # WARNING: Once set, changing models requires rebuilding vector collections - - EMBED_MODEL=${EMBED_MODEL:-} + - EMBED_MODEL=${EMBED_MODEL:-all-MiniLM-L6-v2} # Serve the React shell from FastAPI - ARKHAM_SERVE_SHELL=true # Auth security (REQUIRED for production - generate with: openssl rand -hex 32) diff --git a/docs/Screens/mediaforensics.png b/docs/Screens/mediaforensics.png new file mode 100644 index 0000000..0c7a3ca Binary files /dev/null and b/docs/Screens/mediaforensics.png differ diff --git a/packages/arkham-shard-media-forensics/README.md b/packages/arkham-shard-media-forensics/README.md new file mode 100644 index 0000000..0f94946 --- /dev/null +++ b/packages/arkham-shard-media-forensics/README.md @@ -0,0 +1,137 @@ +# Media Forensics Shard + +Media metadata extraction and forensic analysis for images within the SHATTERED framework. + +## Overview + +The Media Forensics shard provides comprehensive image authenticity analysis capabilities, including: + +- **EXIF Extraction**: Extract and analyze embedded metadata from images (camera info, GPS, timestamps) +- **Perceptual Hashing**: Generate perceptual hashes for image similarity detection +- **C2PA Verification**: Verify Content Authenticity Initiative (C2PA) provenance data +- **ELA Analysis**: Error Level Analysis to detect potential image manipulation +- **Sun Position Verification**: Cross-reference claimed photo timestamps with calculated sun positions +- **Similar Image Detection**: Find visually similar images across the document corpus +- **Reverse Image Search**: Search for image appearances across the web + +## Installation + +```bash +cd packages/arkham-shard-media-forensics +pip install -e . + +# For C2PA support (optional) +pip install -e ".[c2pa]" +``` + +## Dependencies + +- **arkham-frame**: Core framework (required) +- **Pillow**: Image processing +- **exifread**: EXIF metadata extraction +- **pysolar**: Sun position calculations +- **scipy**: Scientific computing for ELA +- **c2pa-python**: C2PA verification (optional) + +## Configuration + +### Reverse Image Search API Keys (Optional) + +The similar image search feature works in two modes: + +1. **Without API Keys (Default)**: Generates clickable URLs to perform manual reverse image searches on: + - Google Lens + - Google Images + - TinEye + - Yandex Images + - Bing Visual Search + +2. **With API Keys**: Enables automated reverse image search with results returned directly in the UI. + +#### Available API Keys + +Add these to your `.env` file or `docker-compose.yml` environment section: + +| Variable | Service | Cost | Sign Up | +|----------|---------|------|---------| +| `TINEYE_API_KEY` | TinEye | Paid | [tineye.com/developers](https://tineye.com/developers) | +| `GOOGLE_VISION_API_KEY` | Google Cloud Vision | Free tier + paid | [Cloud Console](https://console.cloud.google.com/apis/library/vision.googleapis.com) | +| `SERPAPI_KEY` | SerpAPI (Google Images) | Free tier (100/mo) | [serpapi.com](https://serpapi.com/) | + +#### Example Configuration + +**In `.env` file:** +```bash +# TinEye - Commercial reverse image search with extensive database +TINEYE_API_KEY=your-tineye-api-key + +# Google Cloud Vision - Web detection and similar images +GOOGLE_VISION_API_KEY=your-google-vision-api-key + +# SerpAPI - Google Images results (has free tier: 100 searches/month) +SERPAPI_KEY=your-serpapi-key +``` + +**In `docker-compose.yml`:** +```yaml +services: + app: + environment: + - TINEYE_API_KEY=${TINEYE_API_KEY} + - GOOGLE_VISION_API_KEY=${GOOGLE_VISION_API_KEY} + - SERPAPI_KEY=${SERPAPI_KEY} +``` + +#### Recommended Setup + +For most users, we recommend: + +1. **Start without API keys** - The manual search URLs work well for occasional use +2. **Add SerpAPI** if you need automated results - Their free tier (100 searches/month) is generous +3. **Add TinEye** for professional use - Best database for finding image origins and modifications + +## API Endpoints + +All endpoints are prefixed with `/api/media-forensics/`: + +- `POST /analyze/{document_id}` - Run full forensic analysis on an image +- `GET /analyses/{analysis_id}` - Get analysis results +- `GET /analyses/count` - Get total analysis count (for navigation badge) +- `GET /document/{document_id}/metadata` - Get extracted EXIF metadata +- `GET /document/{document_id}/ela` - Get ELA analysis image +- `GET /document/{document_id}/c2pa` - Get C2PA verification results +- `POST /similar` - Find similar images by perceptual hash + +## Events + +### Published +- `media.metadata.extracted` - EXIF/metadata extraction complete +- `media.hash.computed` - Perceptual hash generated +- `media.c2pa.verified` - C2PA verification complete +- `media.ela.generated` - ELA analysis complete +- `media.similar.found` - Similar images detected + +### Subscribed +- `document.ingested` - Auto-analyze newly ingested images +- `document.processed` - Process images after document processing + +## Architecture + +``` +arkham_shard_media_forensics/ +├── __init__.py # Package exports +├── shard.py # Shard implementation +├── api.py # FastAPI routes +├── models.py # Pydantic models +└── services/ + ├── __init__.py + ├── exif_service.py # EXIF extraction + ├── hash_service.py # Perceptual hashing + ├── c2pa_service.py # C2PA verification + ├── ela_service.py # Error Level Analysis + └── sun_service.py # Sun position verification +``` + +## License + +MIT diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/__init__.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/__init__.py new file mode 100644 index 0000000..90e275b --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/__init__.py @@ -0,0 +1,6 @@ +"""ArkhamFrame Media Forensics Shard - Media metadata extraction and forensic analysis.""" + +from .shard import MediaForensicsShard + +__version__ = "0.1.0" +__all__ = ["MediaForensicsShard"] diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/api.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/api.py new file mode 100644 index 0000000..b713bbe --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/api.py @@ -0,0 +1,1189 @@ +"""Media Forensics Shard API endpoints.""" + +import logging +import os +import shutil +import uuid +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, Optional + +from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +if TYPE_CHECKING: + from .shard import MediaForensicsShard + +logger = logging.getLogger(__name__) + + +def _to_python_native(value: Any) -> Any: + """Convert numpy types to Python native types for JSON serialization.""" + if value is None: + return None + try: + import numpy as np + if isinstance(value, np.bool_): + return bool(value) + if isinstance(value, np.integer): + return int(value) + if isinstance(value, np.floating): + return float(value) + except ImportError: + pass + return value + +router = APIRouter(prefix="/api/media-forensics", tags=["media-forensics"]) + +# Shard reference set by init_api +_shard: Optional["MediaForensicsShard"] = None + + +def get_shard(request: Request) -> "MediaForensicsShard": + """Get the Media Forensics shard instance from app state.""" + shard = getattr(request.app.state, "media_forensics_shard", None) + if not shard: + raise HTTPException(status_code=503, detail="Media Forensics shard not available") + return shard + + +def init_api(shard: "MediaForensicsShard"): + """Initialize API with shard reference.""" + global _shard + _shard = shard + logger.info("Media Forensics API initialized") + + +# =========================================== +# Request/Response Models +# =========================================== + + +class AnalyzeRequest(BaseModel): + """Request to analyze a document.""" + document_id: str + + +class BatchAnalyzeRequest(BaseModel): + """Request to analyze multiple documents.""" + document_ids: list[str] + + +class ELARequest(BaseModel): + """Request to generate ELA analysis.""" + analysis_id: str + quality: int = Field(default=95, ge=70, le=100) + scale: int = Field(default=15, ge=5, le=30) + + +class SunPositionManualRequest(BaseModel): + """Request to calculate sun position with manual coordinates.""" + latitude: float = Field(..., ge=-90, le=90) + longitude: float = Field(..., ge=-180, le=180) + datetime: str # ISO format + + +class SunPositionRequest(BaseModel): + """Request to verify sun position from an analysis.""" + analysis_id: str + override_location: Optional[dict] = None # {"lat": float, "lon": float} + override_time: Optional[str] = None # ISO format + + +class SimilarSearchRequest(BaseModel): + """Request to find similar images.""" + analysis_id: str + hash_type: str = Field(default="phash", pattern="^(phash|dhash|ahash)$") + threshold: int = Field(default=15, ge=0, le=64) # Default 15 allows more visually similar results + search_type: str = Field(default="internal", pattern="^(internal|external|both)$") + limit: int = Field(default=50, ge=1, le=100) + + +# =========================================== +# Badge/Count Endpoint +# =========================================== + + +@router.get("/analyses/count") +async def get_analyses_count(request: Request): + """Get count of analyses for badge display.""" + shard = get_shard(request) + try: + count = await shard.get_analysis_count() + return {"count": count} + except Exception as e: + logger.error(f"Failed to get count: {e}") + return {"count": 0} + + +# =========================================== +# Analysis Endpoints +# =========================================== + + +@router.get("/analyses") +async def list_analyses( + request: Request, + limit: Annotated[int, Query(ge=1, le=500)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, + status: Annotated[Optional[str], Query()] = None, + verification_status: Annotated[Optional[str], Query()] = None, + has_c2pa: Annotated[Optional[bool], Query()] = None, + has_warnings: Annotated[Optional[bool], Query()] = None, + has_findings: Annotated[Optional[bool], Query()] = None, + integrity_status: Annotated[Optional[str], Query()] = None, + doc_id: Annotated[Optional[str], Query()] = None, +): + """ + List media analyses with optional filtering. + + - **limit**: Maximum number of results (default 50, max 500) + - **offset**: Number of results to skip for pagination + - **status**: Filter by analysis status (pending, processing, completed, failed) + - **verification_status**: Filter by verification status (verified, flagged, unknown, tampered) + - **has_c2pa**: Filter by C2PA presence (true/false) + - **has_warnings**: Filter by warning presence (true/false) + - **has_findings**: Filter by findings presence (true/false) + - **integrity_status**: Filter by integrity status (verified, flagged, unverified, unknown) + - **doc_id**: Filter by document ID + """ + shard = get_shard(request) + + try: + items = await shard.list_analyses( + limit=limit, + offset=offset, + status=status, + verification_status=verification_status, + has_c2pa=has_c2pa, + has_warnings=has_warnings, + has_findings=has_findings, + integrity_status=integrity_status, + doc_id=doc_id, + ) + + total = await shard.get_analysis_count() + has_more = offset + len(items) < total + + return { + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "has_more": has_more, + } + + except Exception as e: + logger.error(f"Failed to list analyses: {e}") + raise HTTPException(status_code=500, detail=f"Failed to list analyses: {str(e)}") + + +@router.get("/analyses/{analysis_id}") +async def get_analysis(request: Request, analysis_id: str): + """Get a specific analysis by ID.""" + shard = get_shard(request) + + analysis = await shard.get_analysis(analysis_id) + if not analysis: + raise HTTPException(status_code=404, detail=f"Analysis not found: {analysis_id}") + + return analysis + + +@router.get("/document/{document_id}") +async def get_analysis_by_document(request: Request, document_id: str): + """Get analysis for a specific document.""" + shard = get_shard(request) + + analysis = await shard.get_analysis_by_document(document_id) + if not analysis: + raise HTTPException(status_code=404, detail=f"No analysis found for document: {document_id}") + + return analysis + + +# =========================================== +# Analysis Actions +# =========================================== + + +@router.post("/analyze") +async def analyze_document(request: Request, body: AnalyzeRequest): + """ + Analyze a single document for media forensics. + + Extracts EXIF metadata, computes perceptual hashes, and parses C2PA content credentials. + Auto-finds similar images using perceptual hashing. + + Returns: + Full analysis results including: + - EXIF/XMP metadata + - Perceptual hashes (pHash, dHash, aHash) + - C2PA content credentials (if present) + - Warnings and anomalies detected + - Integrity status assessment + """ + shard = get_shard(request) + + try: + result = await shard.analyze_document(body.document_id) + return result + + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Analysis failed: {e}") + raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + + +@router.post("/analyze/batch") +async def analyze_batch(request: Request, body: BatchAnalyzeRequest): + """ + Analyze multiple documents in batch. + + Performs full media forensics analysis on each document. + + Returns: + List of analysis results with status per document. + """ + shard = get_shard(request) + + results = [] + for doc_id in body.document_ids: + try: + result = await shard.analyze_document(doc_id) + results.append({ + "document_id": doc_id, + "status": "success", + "analysis_id": result["analysis_id"], + "integrity_status": result["integrity_status"], + }) + except FileNotFoundError as e: + results.append({ + "document_id": doc_id, + "status": "error", + "error": str(e), + }) + except Exception as e: + logger.error(f"Batch analysis failed for {doc_id}: {e}") + results.append({ + "document_id": doc_id, + "status": "error", + "error": str(e), + }) + + return { + "results": results, + "total": len(results), + "successful": len([r for r in results if r["status"] == "success"]), + "failed": len([r for r in results if r["status"] == "error"]), + } + + +@router.post("/upload") +async def upload_and_analyze( + request: Request, + file: UploadFile = File(...), + run_ela: bool = Query(default=False, description="Also generate ELA analysis"), +): + """ + Upload an image file and analyze it directly. + + This endpoint allows direct file upload for media forensics analysis + without going through the standard document ingest pipeline. + + Accepts: JPEG, PNG, TIFF, WebP, GIF, BMP images. + + **Parameters:** + - **file**: Image file to analyze + - **run_ela**: Also generate ELA analysis (default: false) + + **Returns:** + Full analysis results including: + - EXIF/XMP metadata + - Perceptual hashes (pHash, dHash, aHash) + - C2PA content credentials (if present) + - Warnings and anomalies detected + - Integrity status assessment + - ELA results (if run_ela=true) + """ + shard = get_shard(request) + + # Validate file type + allowed_types = { + "image/jpeg", "image/png", "image/tiff", "image/webp", + "image/gif", "image/bmp", "image/jpg" + } + content_type = file.content_type or "" + filename = file.filename or "unknown" + + # Check by extension if content_type is generic + ext = Path(filename).suffix.lower() + ext_to_mime = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".tiff": "image/tiff", ".tif": "image/tiff", ".webp": "image/webp", + ".gif": "image/gif", ".bmp": "image/bmp" + } + + if content_type not in allowed_types: + if ext in ext_to_mime: + content_type = ext_to_mime[ext] + else: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type: {content_type}. Supported: JPEG, PNG, TIFF, WebP, GIF, BMP" + ) + + try: + # Save uploaded file to persistent location (not temp, since ELA needs it later) + upload_id = str(uuid.uuid4()) + + # Get storage path from frame config + storage_path = "data_silo" + if hasattr(shard._frame, 'config'): + config = shard._frame.config + if hasattr(config, 'get'): + storage_path = config.get("storage_path", "data_silo") + elif hasattr(config, 'storage_path'): + storage_path = config.storage_path or "data_silo" + + # Store in media_forensics subdirectory (persistent for ELA) + media_dir = Path(storage_path) / "media_forensics" + media_dir.mkdir(parents=True, exist_ok=True) + + # Preserve original extension with unique ID + stored_path = media_dir / f"{upload_id}{ext}" + + with open(stored_path, "wb") as f: + shutil.copyfileobj(file.file, f) + + logger.info(f"Saved uploaded file to {stored_path}") + + # Run analysis directly on file (file_path stored for future ELA) + result = await shard.analyze_file( + file_path=stored_path, + filename=filename, + run_ela=run_ela, + ) + + return result + + except Exception as e: + logger.error(f"Upload analysis failed: {e}") + raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + + +@router.post("/analyze/path") +async def analyze_file_path( + request: Request, + file_path: str = Query(..., description="Absolute path to image file"), + run_ela: bool = Query(default=False, description="Also generate ELA analysis"), +): + """ + Analyze an image file by its file system path. + + This endpoint allows analysis of images already on the server + without uploading. + + **Parameters:** + - **file_path**: Absolute path to the image file + - **run_ela**: Also generate ELA analysis (default: false) + + **Returns:** + Full analysis results. + """ + shard = get_shard(request) + + path = Path(file_path) + if not path.exists(): + raise HTTPException(status_code=404, detail=f"File not found: {file_path}") + + if not path.is_file(): + raise HTTPException(status_code=400, detail=f"Not a file: {file_path}") + + # Check extension + ext = path.suffix.lower() + allowed_ext = {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".webp", ".gif", ".bmp"} + if ext not in allowed_ext: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type: {ext}. Supported: {', '.join(allowed_ext)}" + ) + + try: + result = await shard.analyze_file( + file_path=path, + filename=path.name, + run_ela=run_ela, + ) + return result + + except Exception as e: + logger.error(f"Path analysis failed: {e}") + raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + + +# =========================================== +# ELA (Error Level Analysis) Endpoints +# =========================================== + + +@router.post("/ela") +async def generate_ela(request: Request, body: ELARequest): + """ + Generate Error Level Analysis (ELA) for an analyzed image. + + ELA works by re-saving the image at a known quality level and comparing + the difference. Modified regions may show different error levels. + + **Parameters:** + - **analysis_id**: ID of the analysis to generate ELA for + - **quality**: JPEG quality for resave (70-100, default 95) + - **scale**: Multiplier for error visualization (5-30, default 15) + + **Returns:** + - Base64-encoded ELA visualization image + - Interpretation of results + - List of caveats about ELA reliability + + **Important caveats:** + - ELA is NOT definitive proof of manipulation + - Different compression levels in original cause natural variations + - Works best on JPEG images + """ + shard = get_shard(request) + + if not shard.ela_analyzer: + raise HTTPException( + status_code=503, + detail="ELA analyzer not available. Pillow library may not be installed." + ) + + try: + result = await shard.generate_ela( + analysis_id=body.analysis_id, + quality=body.quality, + scale=body.scale, + ) + + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("error", "ELA generation failed")) + + # Transform result to match frontend expected format (ELAResult) + interpretation = result.get("interpretation", {}) + ela_result = { + "id": str(uuid.uuid4()), + "doc_id": "", + "analysis_id": body.analysis_id, + "quality_level": result.get("quality_used", body.quality), + "original_image_url": None, + "ela_image_url": None, + "ela_image_base64": result.get("ela_image_base64"), + "global_avg_intensity": interpretation.get("mean_error", 0), + "global_max_intensity": interpretation.get("max_error", 0), + "suspicious_regions": [], # TODO: detect suspicious regions + "is_potentially_edited": interpretation.get("uniformity_score", 1.0) < 0.5 or interpretation.get("std_error", 0) > 30, + "confidence": interpretation.get("uniformity_score", 0.5), + "generated_at": datetime.utcnow().isoformat(), + } + + return {"result": ela_result} + + except HTTPException: + raise + except Exception as e: + logger.error(f"ELA generation failed: {e}") + raise HTTPException(status_code=500, detail=f"ELA generation failed: {str(e)}") + + +# =========================================== +# Sun Position Verification Endpoints +# =========================================== + + +@router.post("/sun-position") +async def verify_sun_position(request: Request, body: SunPositionRequest): + """ + Verify sun position for an analysis using EXIF data or overrides. + + Uses the GPS coordinates and timestamp from EXIF metadata (or provided overrides) + to calculate the expected sun position at the claimed time and location. + + **Parameters:** + - **analysis_id**: ID of the analysis to verify + - **override_location**: Optional override for GPS coordinates {"lat": float, "lon": float} + - **override_time**: Optional override for timestamp (ISO format) + + **Returns:** + - Sun position result with consistency assessment + """ + shard = get_shard(request) + + if not shard.sun_position: + raise HTTPException( + status_code=503, + detail="Sun position service not available. Pysolar library may not be installed." + ) + + try: + # Get the analysis + analysis = await shard.get_analysis(body.analysis_id) + if not analysis: + raise HTTPException(status_code=404, detail=f"Analysis not found: {body.analysis_id}") + + exif_data = analysis.get("exif_data", {}) + gps = exif_data.get("gps", {}) + timestamps = exif_data.get("timestamps", {}) + + # Use overrides if provided, otherwise use EXIF data + if body.override_location: + latitude = body.override_location.get("lat") + longitude = body.override_location.get("lon") + else: + latitude = gps.get("latitude") + longitude = gps.get("longitude") + + if body.override_time: + claimed_time = body.override_time + else: + claimed_time = ( + timestamps.get("datetime_original") + or timestamps.get("datetime_digitized") + or timestamps.get("datetime_modified") + ) + + # Validate we have required data + if latitude is None or longitude is None: + return { + "result": { + "id": str(uuid.uuid4()), + "doc_id": analysis.get("document_id", ""), + "analysis_id": body.analysis_id, + "claimed_location": None, + "claimed_time": claimed_time, + "calculated_sun_position": None, + "shadow_analysis": None, + "is_consistent": True, # Can't verify without location + "inconsistency_details": ["No GPS coordinates available for verification"], + "confidence": 0.0, + "generated_at": datetime.utcnow().isoformat(), + } + } + + if not claimed_time: + return { + "result": { + "id": str(uuid.uuid4()), + "doc_id": analysis.get("document_id", ""), + "analysis_id": body.analysis_id, + "claimed_location": {"lat": latitude, "lon": longitude}, + "claimed_time": None, + "calculated_sun_position": None, + "shadow_analysis": None, + "is_consistent": True, # Can't verify without time + "inconsistency_details": ["No timestamp available for verification"], + "confidence": 0.0, + "generated_at": datetime.utcnow().isoformat(), + } + } + + # Parse datetime + try: + # Handle EXIF format (YYYY:MM:DD HH:MM:SS) and ISO format + if ":" == claimed_time[4:5] and ":" == claimed_time[7:8]: + # EXIF format - convert colons in date part to dashes + claimed_time = claimed_time.replace(":", "-", 2) + dt = datetime.fromisoformat(claimed_time.replace("Z", "+00:00")) + except Exception as e: + logger.warning(f"Could not parse timestamp: {claimed_time} - {e}") + dt = datetime.utcnow() + + # Calculate sun position + sun_result = await shard.calculate_sun_position( + latitude=latitude, + longitude=longitude, + dt=dt, + ) + + if not sun_result.get("success"): + raise HTTPException(status_code=400, detail=sun_result.get("error", "Calculation failed")) + + # Convert numpy types to Python native for JSON serialization + sun_above_horizon = bool(sun_result.get("sun_above_horizon", True)) + sun_azimuth = _to_python_native(sun_result.get("sun_azimuth", 0)) + sun_altitude = _to_python_native(sun_result.get("sun_altitude", 0)) + shadow_direction = _to_python_native(sun_result.get("expected_shadow_direction")) + + # Build the response in the format the frontend expects + return { + "result": { + "id": str(uuid.uuid4()), + "doc_id": analysis.get("document_id", ""), + "analysis_id": body.analysis_id, + "claimed_location": {"lat": float(latitude), "lon": float(longitude)}, + "claimed_time": dt.isoformat(), + "calculated_sun_position": { + "azimuth": sun_azimuth, + "altitude": sun_altitude, + "calculated_time": dt.isoformat(), + "location_lat": float(latitude), + "location_lon": float(longitude), + }, + "shadow_analysis": { + "detected_shadows": [], + "average_shadow_direction": shadow_direction, + "consistency_score": 1.0 if sun_above_horizon else 0.5, + }, + "is_consistent": sun_above_horizon, + "inconsistency_details": [] if sun_above_horizon else [ + "Sun was below the horizon at the claimed time" + ], + "confidence": 0.85 if sun_above_horizon else 0.3, + "generated_at": datetime.utcnow().isoformat(), + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Sun position verification failed: {e}") + raise HTTPException(status_code=500, detail=f"Verification failed: {str(e)}") + + +@router.post("/sun-position/manual") +async def calculate_sun_position_manual(request: Request, body: SunPositionManualRequest): + """ + Calculate sun position for given coordinates and time (manual input). + + Uses the NOAA solar position algorithm to compute: + - Sun altitude (elevation above horizon) + - Sun azimuth (compass direction) + - Expected shadow direction (opposite of sun) + - Shadow length ratio (relative to object height) + + **Parameters:** + - **latitude**: Latitude in decimal degrees (-90 to 90) + - **longitude**: Longitude in decimal degrees (-180 to 180) + - **datetime**: ISO format datetime string with timezone + + **Returns:** + - Sun altitude and azimuth + - Expected shadow direction + - Interpretation text + """ + shard = get_shard(request) + + if not shard.sun_position: + raise HTTPException( + status_code=503, + detail="Sun position service not available. Pysolar library may not be installed." + ) + + try: + # Parse datetime + dt = datetime.fromisoformat(body.datetime.replace("Z", "+00:00")) + + result = await shard.calculate_sun_position( + latitude=body.latitude, + longitude=body.longitude, + dt=dt, + ) + + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("error", "Calculation failed")) + + return result + + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid datetime format: {str(e)}") + except HTTPException: + raise + except Exception as e: + logger.error(f"Sun position calculation failed: {e}") + raise HTTPException(status_code=500, detail=f"Calculation failed: {str(e)}") + + +@router.get("/sun-position/{analysis_id}") +async def get_sun_position_from_exif(request: Request, analysis_id: str): + """ + Calculate sun position from an analyzed image's EXIF data. + + Uses the GPS coordinates and timestamp from EXIF metadata to + automatically calculate the expected sun position at the time + and place the photo was supposedly taken. + + **Requirements:** + - Analysis must have GPS coordinates in EXIF + - Analysis must have timestamp in EXIF + + **Returns:** + - Sun position data + - Interpretation for shadow verification + """ + shard = get_shard(request) + + if not shard.sun_position: + raise HTTPException( + status_code=503, + detail="Sun position service not available. Pysolar library may not be installed." + ) + + try: + result = await shard.get_sun_position_from_analysis(analysis_id) + + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("error", "Calculation failed")) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Sun position calculation failed: {e}") + raise HTTPException(status_code=500, detail=f"Calculation failed: {str(e)}") + + +# =========================================== +# Similar Image Search Endpoints +# =========================================== + + +@router.post("/similar") +async def find_similar_images(request: Request, body: SimilarSearchRequest): + """ + Find images similar to a given analysis using perceptual hashing. + + Perceptual hashing creates a fingerprint of an image that is + similar for visually similar images, even after resizing, compression, + or minor modifications. + + **Parameters:** + - **analysis_id**: Source analysis to find similar images for + - **hash_type**: Type of perceptual hash to use (phash, dhash, ahash) + - **threshold**: Maximum Hamming distance (0-64, lower = more similar) + + **Hash types:** + - **phash**: Perceptual hash - good general purpose + - **dhash**: Difference hash - fast, good for detecting crops + - **ahash**: Average hash - simplest, may have more false positives + + **Returns:** + - List of similar images with similarity scores + """ + shard = get_shard(request) + + if not shard.hash_service: + raise HTTPException( + status_code=503, + detail="Hash service not available. ImageHash library may not be installed." + ) + + try: + # Transform results to match frontend expected format + transformed_images = [] + exact_matches = 0 + near_duplicates = 0 + visually_similar = 0 + + # Internal search: search within local database + if body.search_type in ("internal", "both"): + similar = await shard.find_similar_images( + analysis_id=body.analysis_id, + hash_type=body.hash_type, + threshold=body.threshold, + ) + + for img in similar[:body.limit]: + # Determine similarity type based on hamming distance + distance = img.get("hamming_distance", 64) + similarity_score = img.get("similarity_score", 0) + + if distance == 0: + similarity_type = "exact" + exact_matches += 1 + elif distance <= 5: + similarity_type = "near_duplicate" + near_duplicates += 1 + else: + similarity_type = "visually_similar" + visually_similar += 1 + + # Fetch the analysis to get filename + similar_analysis = await shard.get_analysis(img.get("analysis_id")) + filename = "unknown" + if similar_analysis: + filename = similar_analysis.get("filename", "unknown") + + transformed_images.append({ + "id": img.get("analysis_id", str(uuid.uuid4())), + "doc_id": img.get("document_id", ""), + "filename": filename, + "similarity_score": similarity_score, + "similarity_type": similarity_type, + "match_details": { + "hash_distance": distance, + }, + "thumbnail_url": None, + "thumbnail_base64": None, + "source": "internal", + "found_at": datetime.utcnow().isoformat(), + }) + + # External search: reverse image search via web APIs or URL generators + search_urls = [] + if body.search_type in ("external", "both"): + # Get base URL from request for generating image URLs + base_url = str(request.base_url).rstrip("/") + + # Call the updated reverse_image_search that returns URLs and API results + external_data = await shard.reverse_image_search(body.analysis_id, base_url) + + # Add search URLs for manual searching + search_urls = external_data.get("search_urls", []) + + # Add any API-based results + for result in external_data.get("api_results", [])[:body.limit - len(transformed_images)]: + transformed_images.append({ + "id": str(uuid.uuid4()), + "doc_id": "", + "filename": result.get("title", "External Result"), + "similarity_score": result.get("similarity_score", 0.5), + "similarity_type": "content_similar", + "match_details": { + "source_url": result.get("url"), + "source_domain": result.get("domain"), + }, + "thumbnail_url": result.get("thumbnail_url"), + "thumbnail_base64": None, + "source": result.get("source", "external"), + "found_at": datetime.utcnow().isoformat(), + }) + + return { + "result": { + "id": str(uuid.uuid4()), + "doc_id": "", + "analysis_id": body.analysis_id, + "search_type": body.search_type, + "total_found": len(transformed_images), + "similar_images": transformed_images, + "exact_matches": exact_matches, + "near_duplicates": near_duplicates, + "visually_similar": len(transformed_images) - exact_matches - near_duplicates, + "generated_at": datetime.utcnow().isoformat(), + "search_urls": search_urls, # URLs for manual reverse image search + } + } + + except Exception as e: + logger.error(f"Similar image search failed: {e}") + raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}") + + +# =========================================== +# Image Serving Endpoint (for reverse image search) +# =========================================== + + +@router.get("/image/{analysis_id}") +async def serve_analysis_image(request: Request, analysis_id: str): + """ + Serve the original image file for an analysis. + + This endpoint is used to provide a URL for external reverse image + search engines. The URL can be used with Google Lens, Yandex Images, + TinEye, and other services. + + **Note:** For URL-based reverse image search to work, this endpoint + must be accessible from the internet (not just localhost). + """ + shard = get_shard(request) + + try: + analysis = await shard.get_analysis(analysis_id) + if not analysis: + raise HTTPException(status_code=404, detail="Analysis not found") + + file_path = analysis.get("file_path") + if not file_path: + raise HTTPException(status_code=404, detail="No file path in analysis") + + path = Path(file_path) + if not path.exists(): + raise HTTPException(status_code=404, detail="Image file not found") + + # Determine content type from extension + ext_to_mime = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".tiff": "image/tiff", + ".tif": "image/tiff", + } + content_type = ext_to_mime.get(path.suffix.lower(), "application/octet-stream") + + # Stream the file + def iter_file(): + with open(path, "rb") as f: + while chunk := f.read(65536): # 64KB chunks + yield chunk + + return StreamingResponse( + iter_file(), + media_type=content_type, + headers={ + "Content-Disposition": f'inline; filename="{path.name}"', + "Cache-Control": "public, max-age=3600", + }, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to serve image: {e}") + raise HTTPException(status_code=500, detail=f"Failed to serve image: {str(e)}") + + +# =========================================== +# Statistics Endpoint +# =========================================== + + +@router.get("/stats") +async def get_stats(request: Request): + """ + Get media forensics statistics. + + Returns aggregated statistics including: + - Total number of analyses + - Count with EXIF metadata + - Count with GPS data + - Count with C2PA content credentials + - Count with warnings/anomalies + - AI-generated images detected + - Breakdown by integrity status + - Breakdown by file type + """ + shard = get_shard(request) + + try: + stats = await shard.get_stats() + return {"stats": stats} + + except Exception as e: + logger.error(f"Failed to get stats: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}") + + +# =========================================== +# C2PA Support Check +# =========================================== + + +@router.get("/c2pa/supported") +async def check_c2pa_support(request: Request): + """ + Check which optional features are available. + + Returns availability of: + - C2PA parsing (requires c2pa-python library) + - C2PA signature verification + - Sun position calculations (requires pysolar library) + """ + shard = get_shard(request) + + return { + "c2pa_available": shard.c2pa_parser is not None, + "signature_verification_available": ( + shard.c2pa_parser is not None + and hasattr(shard.c2pa_parser, "verify_signature") + ), + "pysolar_available": shard.sun_position is not None, + "imagehash_available": shard.hash_service is not None, + "ela_available": shard.ela_analyzer is not None, + "exif_available": shard.exif_extractor is not None, + } + + +# =========================================== +# Bulk Operations +# =========================================== + + +@router.delete("/analyses/{analysis_id}") +async def delete_analysis(request: Request, analysis_id: str): + """Delete an analysis record.""" + shard = get_shard(request) + + if not shard._db: + raise HTTPException(status_code=503, detail="Database not available") + + # Check if analysis exists + analysis = await shard.get_analysis(analysis_id) + if not analysis: + raise HTTPException(status_code=404, detail=f"Analysis not found: {analysis_id}") + + try: + # Delete related records first (cascade should handle this, but explicit is safer) + await shard._db.execute( + "DELETE FROM arkham_media_ela WHERE analysis_id = :id", + {"id": analysis_id} + ) + await shard._db.execute( + "DELETE FROM arkham_media_sun_verification WHERE analysis_id = :id", + {"id": analysis_id} + ) + await shard._db.execute( + "DELETE FROM arkham_media_similar WHERE source_analysis_id = :id OR target_analysis_id = :id", + {"id": analysis_id} + ) + await shard._db.execute( + "DELETE FROM arkham_media_analyses WHERE id = :id", + {"id": analysis_id} + ) + + # Emit event + if shard._event_bus: + await shard._event_bus.emit( + "media.analysis.deleted", + { + "analysis_id": analysis_id, + "document_id": analysis["document_id"], + }, + source="media-forensics", + ) + + return {"status": "deleted", "analysis_id": analysis_id} + + except Exception as e: + logger.error(f"Failed to delete analysis: {e}") + raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}") + + +@router.post("/{analysis_id}/reanalyze") +async def reanalyze(request: Request, analysis_id: str): + """ + Re-run analysis on an existing record. + + Useful when analysis algorithms are updated or to refresh stale data. + """ + shard = get_shard(request) + + # Get existing analysis + analysis = await shard.get_analysis(analysis_id) + if not analysis: + raise HTTPException(status_code=404, detail=f"Analysis not found: {analysis_id}") + + try: + # Re-run analysis on the same document + result = await shard.analyze_document(analysis["document_id"]) + + return { + "status": "reanalyzed", + "old_analysis_id": analysis_id, + "new_analysis_id": result["analysis_id"], + "changes_detected": result["integrity_status"] != analysis["integrity_status"], + } + + except Exception as e: + logger.error(f"Reanalysis failed: {e}") + raise HTTPException(status_code=500, detail=f"Reanalysis failed: {str(e)}") + + +# =========================================== +# AI Junior Analyst Endpoint +# =========================================== + + +class AIJuniorAnalystRequest(BaseModel): + """Request for AI Junior Analyst analysis.""" + target_id: str + context: dict[str, Any] = {} + depth: str = "quick" + session_id: Optional[str] = None + message: Optional[str] = None + conversation_history: Optional[list[dict[str, str]]] = None + + +@router.post("/ai/junior-analyst") +async def ai_junior_analyst(request: Request, body: AIJuniorAnalystRequest): + """ + AI Junior Analyst endpoint for Media Forensics analysis. + + Provides AI-powered interpretation of media analysis including: + - EXIF metadata significance and anomalies + - C2PA content credential verification + - ELA interpretation for manipulation detection + - Sun position consistency analysis + - Similar image findings + - Overall authenticity assessment + + **Parameters:** + - **target_id**: ID of the analysis to interpret + - **context**: Additional context data + - **depth**: Analysis depth (quick, standard, deep) + - **session_id**: Optional session ID for conversation continuity + - **message**: Optional follow-up message + - **conversation_history**: Optional previous conversation messages + + **Returns:** + - Streaming SSE response with AI analysis + """ + shard = get_shard(request) + frame = shard._frame + + if not frame or not getattr(frame, "ai_analyst", None): + raise HTTPException( + status_code=503, + detail="AI Analyst service not available" + ) + + # Build context from request + try: + from arkham_frame.services import AnalysisRequest, AnalysisDepth, AnalystMessage + except ImportError: + raise HTTPException( + status_code=503, + detail="AI Analyst service not available (import failed)" + ) + + # Parse depth + try: + depth = AnalysisDepth(body.depth) + except ValueError: + depth = AnalysisDepth.QUICK + + # Build conversation history + history = None + if body.conversation_history: + history = [ + AnalystMessage(role=msg["role"], content=msg["content"]) + for msg in body.conversation_history + ] + + analysis_request = AnalysisRequest( + shard="media-forensics", + target_id=body.target_id, + context=body.context, + depth=depth, + session_id=body.session_id, + message=body.message, + conversation_history=history, + ) + + # Stream the response + return StreamingResponse( + frame.ai_analyst.stream_analyze(analysis_request), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/models.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/models.py new file mode 100644 index 0000000..30ad6fd --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/models.py @@ -0,0 +1,282 @@ +"""Data models for the Media Forensics Shard.""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Optional, List, Dict + + +class IntegrityStatus(str, Enum): + """Integrity status of analyzed media.""" + UNKNOWN = "unknown" + VERIFIED = "verified" + FLAGGED = "flagged" + UNVERIFIED = "unverified" + + +class ELAAssessment(str, Enum): + """ELA analysis assessment.""" + UNIFORM = "uniform" + VARIABLE = "variable" + TYPICAL = "typical" + + +class SunVerificationStatus(str, Enum): + """Sun position verification status.""" + CONSISTENT = "consistent" + INCONSISTENT = "inconsistent" + UNCERTAIN = "uncertain" + UNAVAILABLE = "unavailable" + + +class HashType(str, Enum): + """Types of perceptual hashes.""" + PHASH = "phash" + DHASH = "dhash" + AHASH = "ahash" + + +# =========================================== +# API Request Models (Pydantic) +# =========================================== + +from pydantic import BaseModel, Field + + +class AnalyzeRequest(BaseModel): + """Request to analyze a document.""" + document_id: str + + +class BatchAnalyzeRequest(BaseModel): + """Request to analyze multiple documents.""" + document_ids: List[str] + + +class ELARequest(BaseModel): + """Request to generate ELA analysis.""" + analysis_id: str + quality: int = Field(default=95, ge=70, le=100) + scale: int = Field(default=15, ge=5, le=30) + + +class SunPositionRequest(BaseModel): + """Request to calculate sun position.""" + latitude: float = Field(..., ge=-90, le=90) + longitude: float = Field(..., ge=-180, le=180) + datetime: str # ISO format + + +class SimilarSearchRequest(BaseModel): + """Request to find similar images.""" + analysis_id: str + hash_type: str = Field(default="phash", pattern="^(phash|dhash|ahash)$") + threshold: int = Field(default=10, ge=0, le=64) + + +# =========================================== +# API Response Models (Pydantic) +# =========================================== + +class CountResponse(BaseModel): + """Response with count.""" + count: int + + +class AnalysisListResponse(BaseModel): + """Response with list of analyses.""" + items: List[Dict[str, Any]] + total: int + + +class AnalysisResponse(BaseModel): + """Response for a single analysis.""" + analysis_id: str + document_id: str + exif: Dict[str, Any] + hashes: Dict[str, str] + c2pa: Optional[Dict[str, Any]] + c2pa_interpretation: Optional[Dict[str, Any]] + warnings: List[str] + integrity_status: str + + +class BatchAnalyzeResponse(BaseModel): + """Response for batch analysis.""" + results: List[Dict[str, Any]] + + +class ELAResponse(BaseModel): + """Response for ELA analysis.""" + success: bool + ela_image_base64: Optional[str] = None + quality_used: Optional[int] = None + scale_used: Optional[int] = None + interpretation: Optional[Dict[str, Any]] = None + caveats: List[str] = [] + error: Optional[str] = None + + +class SunPositionResponse(BaseModel): + """Response for sun position calculation.""" + success: bool + latitude: Optional[float] = None + longitude: Optional[float] = None + datetime: Optional[str] = None + sun_altitude: Optional[float] = None + sun_azimuth: Optional[float] = None + expected_shadow_direction: Optional[float] = None + shadow_length_ratio: Optional[float] = None + sun_above_horizon: Optional[bool] = None + interpretation: Optional[str] = None + error: Optional[str] = None + + +class SimilarImageResult(BaseModel): + """Result for a similar image.""" + analysis_id: str + hash: str + hamming_distance: int + similarity_score: float + + +class SimilarSearchResponse(BaseModel): + """Response for similar image search.""" + similar_images: List[SimilarImageResult] + + +class StatsResponse(BaseModel): + """Response for statistics.""" + stats: Dict[str, Any] + + +class C2PASupportResponse(BaseModel): + """Response for C2PA support check.""" + c2pa_available: bool + signature_verification_available: bool + pysolar_available: bool + + +# =========================================== +# Data Classes for Internal Use +# =========================================== + +@dataclass +class MediaAnalysis: + """A media analysis record.""" + id: str + document_id: str + tenant_id: Optional[str] = None + + # File info + file_type: Optional[str] = None + file_size: Optional[int] = None + width: Optional[int] = None + height: Optional[int] = None + + # Cryptographic hashes + sha256: Optional[str] = None + md5: Optional[str] = None + + # Perceptual hashes + phash: Optional[str] = None + dhash: Optional[str] = None + ahash: Optional[str] = None + + # EXIF data + exif_data: Dict[str, Any] = field(default_factory=dict) + + # Camera info + camera_make: Optional[str] = None + camera_model: Optional[str] = None + software: Optional[str] = None + + # Timestamps + datetime_original: Optional[str] = None + datetime_digitized: Optional[str] = None + datetime_modified: Optional[str] = None + + # GPS + gps_latitude: Optional[float] = None + gps_longitude: Optional[float] = None + gps_altitude: Optional[float] = None + + # C2PA + c2pa_data: Dict[str, Any] = field(default_factory=dict) + has_c2pa: bool = False + c2pa_signer: Optional[str] = None + c2pa_timestamp: Optional[str] = None + + # Analysis results + warnings: List[str] = field(default_factory=list) + anomalies: List[str] = field(default_factory=list) + integrity_status: str = "unknown" + confidence_score: float = 0.0 + + # Timestamps + created_at: datetime = field(default_factory=datetime.utcnow) + updated_at: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class SimilarImage: + """A similar image match record.""" + id: str + source_analysis_id: str + target_analysis_id: str + hash_type: str + hamming_distance: int + similarity_score: float + created_at: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class ELAResult: + """An ELA analysis result.""" + id: str + analysis_id: str + quality: int = 95 + ela_image_path: Optional[str] = None + + # Analysis results + uniform_regions: List[Dict[str, Any]] = field(default_factory=list) + anomalous_regions: List[Dict[str, Any]] = field(default_factory=list) + interpretation: Optional[str] = None + + created_at: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class SunVerification: + """A sun position verification record.""" + id: str + analysis_id: str + + # Input parameters + claimed_datetime: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + + # Calculated sun position + sun_altitude: Optional[float] = None + sun_azimuth: Optional[float] = None + expected_shadow_direction: Optional[float] = None + + # Verification result + verification_status: str = "unknown" + notes: Optional[str] = None + + created_at: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class AnalysisStats: + """Statistics about media analyses.""" + total_analyses: int = 0 + with_exif: int = 0 + with_gps: int = 0 + with_c2pa: int = 0 + with_warnings: int = 0 + ai_generated_detected: int = 0 + by_integrity_status: Dict[str, int] = field(default_factory=dict) + by_file_type: Dict[str, int] = field(default_factory=dict) diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/__init__.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/__init__.py new file mode 100644 index 0000000..de0a29d --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/__init__.py @@ -0,0 +1,15 @@ +"""Media Forensics services.""" + +from .exif_extractor import ExifExtractor +from .perceptual_hash import PerceptualHashService +from .c2pa_parser import C2PAParser +from .ela_analyzer import ELAAnalyzer +from .sun_position import SunPositionService + +__all__ = [ + "ExifExtractor", + "PerceptualHashService", + "C2PAParser", + "ELAAnalyzer", + "SunPositionService", +] diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/c2pa_parser.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/c2pa_parser.py new file mode 100644 index 0000000..1444ca0 --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/c2pa_parser.py @@ -0,0 +1,236 @@ +""" +C2PA (Content Credentials) parsing service. +Verifies provenance and authenticity metadata. +""" + +from typing import Dict, Any, Optional +from pathlib import Path +import json +import os + +import structlog + +logger = structlog.get_logger() + +# Try to import c2pa-python +try: + import c2pa + C2PA_AVAILABLE = True +except ImportError: + C2PA_AVAILABLE = False + logger.info("c2pa-python not installed, C2PA features disabled") + + +class C2PAParser: + """ + Parse and verify C2PA Content Credentials. + + C2PA is an open standard for media provenance adopted by: + - Adobe (Photoshop, Lightroom, Firefly) + - OpenAI (DALL-E) + - Microsoft (Designer) + - Google + - Camera manufacturers (Leica, Nikon) + + AIR-GAP SAFE: This parser makes NO network calls. + - Manifest parsing is fully offline + - Signature verification requires pre-downloaded trust anchors + - Without trust anchors, parsing still works but signatures are unverified + """ + + def __init__(self, frame, trust_anchors_path: Optional[str] = None): + """ + Initialize C2PA parser. + + Args: + frame: SHATTERED frame reference + trust_anchors_path: Optional path to local trust anchors PEM file. + Can also be set via MEDIA_FORENSICS_TRUST_ANCHORS_PATH env var. + If not provided, signature verification is skipped + but manifest parsing still works fully offline. + """ + self.frame = frame + self._signature_verification_available = False + + # Check for trust anchors path (explicit param > env var) + anchors_path = trust_anchors_path or os.environ.get("MEDIA_FORENSICS_TRUST_ANCHORS_PATH") + + if C2PA_AVAILABLE and anchors_path and Path(anchors_path).exists(): + try: + with open(anchors_path) as f: + anchors_content = f.read() + c2pa.load_settings({ + "verify": {"verify_cert_anchors": True}, + "trust": {"trust_anchors": anchors_content} + }) + self._signature_verification_available = True + logger.info("C2PA trust anchors loaded from local file", path=anchors_path) + except Exception as e: + logger.warning("Failed to load C2PA trust anchors", error=str(e)) + else: + logger.info( + "C2PA signature verification disabled (no trust anchors). " + "Manifest parsing still works. For signature verification, " + "set MEDIA_FORENSICS_TRUST_ANCHORS_PATH to a local .pem file." + ) + + def is_available(self) -> bool: + """Check if C2PA parsing is available.""" + return C2PA_AVAILABLE + + def is_signature_verification_available(self) -> bool: + """Check if signature verification is available (requires trust anchors).""" + return self._signature_verification_available + + async def parse(self, file_path: Path) -> Optional[Dict[str, Any]]: + """ + Parse C2PA manifest from a media file. + + Returns: + Dict with manifest data if C2PA credentials exist, None otherwise. + """ + if not C2PA_AVAILABLE: + return {"error": "c2pa-python library not installed", "has_c2pa": False} + + try: + with c2pa.Reader(str(file_path)) as reader: + manifest_json = reader.json() + + if not manifest_json: + return {"has_c2pa": False} + + store = json.loads(manifest_json) if isinstance(manifest_json, str) else manifest_json + + result = { + "has_c2pa": True, + "manifests": [], + "active_manifest": None, + "signature_valid": False, + "signature_verification_available": self._signature_verification_available, + "signer": None, + "timestamp": None, + "actions": [], + "ingredients": [], + "claim_generator": None, + "ai_training_permissions": {}, + "validation_status": [], + "raw_manifest": store, + } + + # Extract active manifest + if "active_manifest" in store: + active_id = store["active_manifest"] + result["active_manifest"] = active_id + + if "manifests" in store and active_id in store["manifests"]: + manifest = store["manifests"][active_id] + + # Claim generator + result["claim_generator"] = manifest.get("claim_generator") + + # Signature info (present in manifest, verification depends on trust anchors) + if "signature_info" in manifest: + sig_info = manifest["signature_info"] + # signature_valid only true if we have trust anchors loaded + result["signature_valid"] = self._signature_verification_available + result["signer"] = sig_info.get("issuer") + result["timestamp"] = sig_info.get("time") + + # Parse assertions + for assertion in manifest.get("assertions", []): + label = assertion.get("label", "") + + # Actions (edit history) + if label == "c2pa.actions": + result["actions"] = assertion.get("data", {}).get("actions", []) + + # AI training permissions + if label == "c2pa.training-mining": + entries = assertion.get("data", {}).get("entries", {}) + result["ai_training_permissions"] = { + "ai_training": entries.get("c2pa.ai_training", {}).get("use"), + "ai_inference": entries.get("c2pa.ai_inference", {}).get("use"), + "data_mining": entries.get("c2pa.data_mining", {}).get("use"), + "ai_generative_training": entries.get("c2pa.ai_generative_training", {}).get("use"), + } + + # Ingredients (source materials) + for ing in manifest.get("ingredients", []): + result["ingredients"].append({ + "title": ing.get("title"), + "format": ing.get("format"), + "instance_id": ing.get("instance_id"), + }) + + # All manifest IDs + if "manifests" in store: + result["manifests"] = list(store["manifests"].keys()) + + return result + + except Exception as e: + logger.warning("C2PA parsing failed", error=str(e), path=str(file_path)) + return {"error": str(e), "has_c2pa": False} + + def interpret_c2pa(self, c2pa_data: Dict) -> Dict[str, Any]: + """ + Interpret C2PA data into human-readable findings. + """ + if not c2pa_data or c2pa_data.get("error") or not c2pa_data.get("has_c2pa"): + return { + "finding": "NO_C2PA", + "confidence": "low", + "interpretation": ( + "No Content Credentials found. This doesn't prove the content is fake, " + "but means we cannot verify its provenance through C2PA." + ), + "implications": [ + "Content may be genuine but created with non-C2PA tools", + "Content credentials may have been stripped", + "Content may be AI-generated without C2PA tagging", + ], + } + + findings = { + "finding": "C2PA_PRESENT", + "confidence": "high" if c2pa_data.get("signature_valid") else "medium", + "signer": c2pa_data.get("signer"), + "timestamp": c2pa_data.get("timestamp"), + "interpretation": "", + "implications": [], + "is_ai_generated": False, + } + + # Interpret based on signer + signer = (c2pa_data.get("signer") or "").lower() + + if "openai" in signer or "dall-e" in signer: + findings["interpretation"] = ( + "This image was generated by OpenAI's DALL-E and has valid Content Credentials." + ) + findings["implications"] = [ + "Image is AI-generated (confirmed by creator)", + "OpenAI has signed this as their creation", + "This is a legitimate disclosure, not a fake", + ] + findings["is_ai_generated"] = True + + elif "adobe" in signer: + findings["interpretation"] = "This content has Adobe Content Credentials." + # Check actions for AI generation + actions = c2pa_data.get("actions", []) + if any("generative" in str(a).lower() or "firefly" in str(a).lower() for a in actions): + findings["implications"].append("Contains AI-generated elements (Adobe Firefly)") + findings["is_ai_generated"] = True + else: + findings["implications"].append("Created or edited with Adobe software") + + elif "microsoft" in signer: + findings["interpretation"] = "This content has Microsoft Content Credentials." + findings["implications"].append("May have been created with Microsoft Designer or Copilot") + + else: + findings["interpretation"] = f"Content signed by: {c2pa_data.get('signer', 'Unknown')}" + findings["implications"].append("Provenance verified through C2PA signature") + + return findings diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/ela_analyzer.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/ela_analyzer.py new file mode 100644 index 0000000..adc8e39 --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/ela_analyzer.py @@ -0,0 +1,191 @@ +""" +Error Level Analysis (ELA) service. +Highlights regions with different compression levels. + +IMPORTANT: ELA has high false positive rates and should be used +as a visualization tool, NOT as definitive evidence of manipulation. +""" + +from typing import Dict, Any +from pathlib import Path +import io +import base64 +import uuid + +from PIL import Image +import numpy as np + +import structlog + +logger = structlog.get_logger() + + +class ELAAnalyzer: + """ + Perform Error Level Analysis on images. + + ELA works by re-saving an image at a known quality level and + comparing the result to the original. Regions that have been + modified may show different error levels. + + CAVEAT: ELA is unreliable for: + - Images saved multiple times + - AI-generated images (often uniform ELA) + - High-quality original images + - Certain types of edits + """ + + def __init__(self, frame): + self.frame = frame + self.storage = frame.get_service("storage") if frame else None + + async def analyze( + self, + file_path: Path, + quality: int = 95, + scale: int = 15, + ) -> Dict[str, Any]: + """ + Perform ELA analysis on an image. + + Args: + file_path: Path to image file + quality: JPEG quality for resave (90-95 recommended) + scale: Multiplier for error visualization (10-20 recommended) + + Returns: + Dict with ELA image, interpretation, and caveats + """ + try: + # Load original image + with Image.open(file_path) as original: + # Convert to RGB if necessary + if original.mode != "RGB": + original = original.convert("RGB") + + original_array = np.array(original) + + # Resave at specified quality + buffer = io.BytesIO() + original.save(buffer, format="JPEG", quality=quality) + buffer.seek(0) + + with Image.open(buffer) as resaved: + resaved_array = np.array(resaved) + + # Compute absolute difference + diff = np.abs(original_array.astype(np.int16) - resaved_array.astype(np.int16)) + + # Scale for visibility + ela = np.clip(diff * scale, 0, 255).astype(np.uint8) + + # Create ELA image + ela_image = Image.fromarray(ela) + + # Convert to base64 for response + ela_buffer = io.BytesIO() + ela_image.save(ela_buffer, format="PNG") + ela_base64 = base64.b64encode(ela_buffer.getvalue()).decode() + + # Analyze the ELA result + interpretation = self._interpret_ela(ela) + + return { + "success": True, + "ela_image_base64": ela_base64, + "quality_used": quality, + "scale_used": scale, + "interpretation": interpretation, + "caveats": [ + "ELA has high false positive rates", + "Uniform ELA may indicate AI generation OR multiple saves", + "Different error levels don't definitively prove manipulation", + "Use as one signal among many, not as proof", + ], + } + + except Exception as e: + logger.error("ELA analysis failed", error=str(e)) + return { + "success": False, + "error": str(e), + } + + def _interpret_ela(self, ela_array: np.ndarray) -> Dict[str, Any]: + """ + Analyze ELA result for patterns. + + Returns interpretation with appropriate caveats. + """ + # Calculate statistics + mean_error = np.mean(ela_array) + std_error = np.std(ela_array) + max_error = np.max(ela_array) + + # Check uniformity + # Split into blocks and check variance + block_size = 64 + h, w = ela_array.shape[:2] + + block_means = [] + for y in range(0, h - block_size, block_size): + for x in range(0, w - block_size, block_size): + block = ela_array[y:y + block_size, x:x + block_size] + block_means.append(np.mean(block)) + + block_std = np.std(block_means) if block_means else 0 + + interpretation = { + "mean_error": float(mean_error), + "std_error": float(std_error), + "max_error": float(max_error), + "uniformity_score": float(1.0 - min(block_std / 50, 1.0)), # 0-1, higher = more uniform + "assessment": "", + "details": [], + } + + # Interpret results (with heavy caveats) + if block_std < 5: + interpretation["assessment"] = "Highly uniform error levels" + interpretation["details"].append( + "The error levels are unusually uniform across the image. " + "This MAY indicate: (1) AI-generated content, (2) multiple JPEG saves, " + "or (3) heavy post-processing. This is NOT definitive." + ) + elif block_std > 20: + interpretation["assessment"] = "Variable error levels detected" + interpretation["details"].append( + "Different regions show different error levels. " + "This MAY indicate editing, or may simply reflect different content " + "(e.g., sky vs. detailed areas naturally compress differently)." + ) + else: + interpretation["assessment"] = "Error levels appear typical" + interpretation["details"].append( + "No obvious anomalies in error level distribution. " + "This does NOT prove the image is authentic." + ) + + return interpretation + + async def save_ela_image( + self, + analysis_id: str, + ela_base64: str, + ) -> str: + """Save ELA image to storage and return path.""" + # Generate filename + filename = f"ela_{analysis_id}_{uuid.uuid4().hex[:8]}.png" + + # Decode and save + ela_bytes = base64.b64decode(ela_base64) + + if self.storage: + path = await self.storage.save( + ela_bytes, + filename=filename, + category="media_forensics", + ) + return path + + return filename diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/exif_extractor.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/exif_extractor.py new file mode 100644 index 0000000..6e6f034 --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/exif_extractor.py @@ -0,0 +1,261 @@ +""" +EXIF/XMP metadata extraction service. +Uses multiple extraction methods for comprehensive coverage. +""" + +from typing import Dict, Any, Optional, List +from pathlib import Path +import re + +from PIL import Image +from PIL.ExifTags import TAGS + +try: + import exifread + EXIFREAD_AVAILABLE = True +except ImportError: + EXIFREAD_AVAILABLE = False + +import structlog + +logger = structlog.get_logger() + + +def _make_serializable(value): + """Convert EXIF value to JSON-serializable type.""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, bytes): + try: + return value.decode("utf-8", errors="replace") + except Exception: + return f"" + if isinstance(value, (list, tuple)): + return [_make_serializable(v) for v in value] + if isinstance(value, dict): + return {str(k): _make_serializable(v) for k, v in value.items()} + # Handle IFDRational, Fraction, and other numeric types + try: + # Try to convert to float first (handles Rational types) + return float(value) + except (TypeError, ValueError): + pass + # Fall back to string representation + return str(value) + + +class ExifExtractor: + """Extract metadata from image files using multiple methods.""" + + def __init__(self, frame): + self.frame = frame + self.storage = frame.get_service("storage") if frame else None + + async def extract_all(self, file_path: Path) -> Dict[str, Any]: + """ + Extract all available metadata from an image file. + + Returns a unified metadata structure regardless of source format. + """ + result = { + "basic": {}, + "exif": {}, + "gps": {}, + "camera": {}, + "timestamps": {}, + "warnings": [], + "extraction_methods": [], + } + + # Method 1: Pillow (fast, basic EXIF) + pillow_data = await self._extract_pillow(file_path) + if pillow_data: + result["exif"].update(pillow_data.get("exif", {})) + result["basic"] = pillow_data.get("basic", {}) + result["extraction_methods"].append("pillow") + + # Method 2: ExifRead (comprehensive, pure Python) + if EXIFREAD_AVAILABLE: + exifread_data = await self._extract_exifread(file_path) + if exifread_data: + # Merge, preferring exifread for detailed data + for key, value in exifread_data.items(): + if key not in result["exif"] or not result["exif"][key]: + result["exif"][key] = value + result["extraction_methods"].append("exifread") + + # Extract structured data from raw EXIF + result["gps"] = self._parse_gps(result["exif"]) + result["camera"] = self._parse_camera(result["exif"]) + result["timestamps"] = self._parse_timestamps(result["exif"]) + + # Check for suspicious patterns + result["warnings"] = self._analyze_metadata_anomalies(result) + + return result + + async def _extract_pillow(self, file_path: Path) -> Optional[Dict]: + """Extract EXIF using Pillow.""" + try: + with Image.open(file_path) as img: + basic = { + "width": img.width, + "height": img.height, + "format": img.format, + "mode": img.mode, + } + + exif_data = img.getexif() + if not exif_data: + return {"basic": basic, "exif": {}} + + exif = {} + for tag_id, value in exif_data.items(): + tag_name = TAGS.get(tag_id, str(tag_id)) + # Convert to JSON-serializable type + exif[tag_name] = _make_serializable(value) + + return {"basic": basic, "exif": exif} + + except Exception as e: + logger.warning("Pillow extraction failed", error=str(e)) + return None + + async def _extract_exifread(self, file_path: Path) -> Optional[Dict]: + """Extract EXIF using ExifRead (more comprehensive).""" + try: + with open(file_path, "rb") as f: + tags = exifread.process_file(f, details=True) + + result = {} + for tag, value in tags.items(): + # Skip thumbnail data + if tag in ("JPEGThumbnail", "TIFFThumbnail", "Filename", "EXIF MakerNote"): + continue + result[tag] = str(value) + + return result + + except Exception as e: + logger.warning("ExifRead extraction failed", error=str(e)) + return None + + def _parse_gps(self, exif: Dict) -> Dict[str, Any]: + """Parse GPS data from EXIF into decimal coordinates.""" + gps = {} + + # Look for GPS tags (different naming in different extractors) + lat_tag = exif.get("GPS GPSLatitude") or exif.get("GPSLatitude") + lat_ref = exif.get("GPS GPSLatitudeRef") or exif.get("GPSLatitudeRef") + lon_tag = exif.get("GPS GPSLongitude") or exif.get("GPSLongitude") + lon_ref = exif.get("GPS GPSLongitudeRef") or exif.get("GPSLongitudeRef") + + if lat_tag and lon_tag: + try: + gps["latitude"] = self._gps_to_decimal(lat_tag, lat_ref) + gps["longitude"] = self._gps_to_decimal(lon_tag, lon_ref) + gps["raw_latitude"] = str(lat_tag) + gps["raw_longitude"] = str(lon_tag) + except Exception as e: + gps["parse_error"] = str(e) + + # Altitude + alt_tag = exif.get("GPS GPSAltitude") or exif.get("GPSAltitude") + if alt_tag: + gps["altitude"] = str(alt_tag) + + return gps + + def _gps_to_decimal(self, coord_str: str, ref: str) -> float: + """Convert GPS coordinates from DMS to decimal degrees.""" + # Parse formats like "[40, 26, 46.302]" or "40/1, 26/1, 46302/1000" + numbers = re.findall(r"[\d.]+(?:/[\d.]+)?", str(coord_str)) + + if len(numbers) >= 3: + def parse_rational(s): + if "/" in s: + num, den = s.split("/") + return float(num) / float(den) + return float(s) + + d = parse_rational(numbers[0]) + m = parse_rational(numbers[1]) + s = parse_rational(numbers[2]) + + decimal = d + (m / 60.0) + (s / 3600.0) + + if ref and str(ref).upper() in ["S", "W"]: + decimal = -decimal + + return round(decimal, 6) + + raise ValueError(f"Could not parse GPS coordinate: {coord_str}") + + def _parse_camera(self, exif: Dict) -> Dict[str, Any]: + """Extract camera/device information.""" + return { + "make": exif.get("Image Make") or exif.get("Make"), + "model": exif.get("Image Model") or exif.get("Model"), + "software": exif.get("Image Software") or exif.get("Software"), + "lens_model": exif.get("EXIF LensModel") or exif.get("LensModel"), + "focal_length": exif.get("EXIF FocalLength") or exif.get("FocalLength"), + "aperture": exif.get("EXIF FNumber") or exif.get("FNumber"), + "iso": exif.get("EXIF ISOSpeedRatings") or exif.get("ISOSpeedRatings"), + "exposure_time": exif.get("EXIF ExposureTime") or exif.get("ExposureTime"), + "flash": exif.get("EXIF Flash") or exif.get("Flash"), + "orientation": exif.get("Image Orientation") or exif.get("Orientation"), + } + + def _parse_timestamps(self, exif: Dict) -> Dict[str, Any]: + """Extract all timestamp-related fields.""" + return { + "datetime_original": exif.get("EXIF DateTimeOriginal") or exif.get("DateTimeOriginal"), + "datetime_digitized": exif.get("EXIF DateTimeDigitized") or exif.get("DateTimeDigitized"), + "datetime_modified": exif.get("Image DateTime") or exif.get("DateTime"), + "gps_timestamp": exif.get("GPS GPSTimeStamp"), + "gps_datestamp": exif.get("GPS GPSDateStamp"), + } + + def _analyze_metadata_anomalies(self, metadata: Dict) -> List[str]: + """Check for suspicious patterns in metadata.""" + warnings = [] + + # Warning: No EXIF at all + if not metadata.get("exif"): + warnings.append( + "NO_EXIF: Image has no EXIF metadata - may indicate AI generation or metadata stripping" + ) + + # Warning: Missing camera info but has other EXIF + if metadata.get("exif") and not metadata.get("camera", {}).get("make"): + warnings.append( + "NO_CAMERA: Has EXIF but missing camera make/model - may indicate editing software origin" + ) + + # Warning: Software field indicates editing + software = metadata.get("camera", {}).get("software", "") or "" + editing_keywords = ["photoshop", "gimp", "lightroom", "canva", "snapseed", "pixlr", "firefly"] + if any(kw in software.lower() for kw in editing_keywords): + warnings.append(f"EDITING_SOFTWARE: Software field indicates editing: {software}") + + # Warning: Timestamp inconsistencies + timestamps = metadata.get("timestamps", {}) + original = timestamps.get("datetime_original") + digitized = timestamps.get("datetime_digitized") + modified = timestamps.get("datetime_modified") + + if original and modified and original != modified: + warnings.append("TIMESTAMP_MISMATCH: Original and modified timestamps differ") + + if digitized and original and digitized != original: + warnings.append("DIGITIZED_MISMATCH: Digitized timestamp differs from original") + + # Warning: GPS present but no camera info + if metadata.get("gps") and not metadata.get("camera", {}).get("make"): + warnings.append( + "GPS_NO_CAMERA: Has GPS data but no camera info - may indicate metadata injection" + ) + + return warnings diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/perceptual_hash.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/perceptual_hash.py new file mode 100644 index 0000000..0ae9a9d --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/perceptual_hash.py @@ -0,0 +1,180 @@ +""" +Perceptual hash computation for image similarity detection. +Computes pHash, dHash, and aHash. +""" + +from typing import Dict, List +from pathlib import Path +import hashlib + +from PIL import Image +import numpy as np + +import structlog + +logger = structlog.get_logger() + + +class PerceptualHashService: + """Compute perceptual hashes for images.""" + + def __init__(self, frame): + self.frame = frame + + async def compute_all_hashes(self, file_path: Path) -> Dict[str, str]: + """Compute all available hashes for an image file.""" + result = { + "sha256": await self._compute_sha256(file_path), + "md5": await self._compute_md5(file_path), + } + + # Perceptual hashes + try: + result["phash"] = await self._compute_phash(file_path) + result["ahash"] = await self._compute_ahash(file_path) + result["dhash"] = await self._compute_dhash(file_path) + except Exception as e: + logger.warning("Perceptual hash computation failed", error=str(e)) + + return result + + async def _compute_sha256(self, file_path: Path) -> str: + """Compute SHA-256 hash.""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + + async def _compute_md5(self, file_path: Path) -> str: + """Compute MD5 hash.""" + md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + md5.update(chunk) + return md5.hexdigest() + + async def _compute_phash(self, file_path: Path) -> str: + """ + Compute perceptual hash (pHash). + Resistant to minor modifications like resizing, compression. + """ + from scipy.fftpack import dct + + with Image.open(file_path) as img: + # Convert to grayscale and resize to 32x32 + img = img.convert("L").resize((32, 32), Image.Resampling.LANCZOS) + pixels = np.array(img, dtype=np.float64) + + # Compute 2D DCT + dct_result = dct(dct(pixels.T, norm="ortho").T, norm="ortho") + + # Use top-left 8x8 (excluding DC component) + dct_low = dct_result[:8, :8] + + # Compute median (excluding DC) + med = np.median(dct_low.flatten()[1:]) + + # Generate hash + diff = dct_low > med + return self._bool_array_to_hex(diff.flatten()) + + async def _compute_ahash(self, file_path: Path) -> str: + """ + Compute average hash (aHash). + Fast but less robust than pHash. + """ + with Image.open(file_path) as img: + img = img.convert("L").resize((8, 8), Image.Resampling.LANCZOS) + pixels = np.array(img) + avg = pixels.mean() + diff = pixels > avg + return self._bool_array_to_hex(diff.flatten()) + + async def _compute_dhash(self, file_path: Path) -> str: + """ + Compute difference hash (dHash). + Good for detecting subtle modifications. + """ + with Image.open(file_path) as img: + img = img.convert("L").resize((9, 8), Image.Resampling.LANCZOS) + pixels = np.array(img) + # Compare adjacent pixels + diff = pixels[:, :-1] > pixels[:, 1:] + return self._bool_array_to_hex(diff.flatten()) + + def _bool_array_to_hex(self, arr: np.ndarray) -> str: + """Convert boolean array to hex string.""" + bits = "".join(["1" if b else "0" for b in arr]) + return hex(int(bits, 2))[2:].zfill(len(arr) // 4) + + def compute_hamming_distance(self, hash1: str, hash2: str) -> int: + """ + Compute Hamming distance between two perceptual hashes. + Lower = more similar. 0 = identical. >10 typically different images. + """ + if len(hash1) != len(hash2): + raise ValueError("Hashes must be same length") + + # Convert hex to binary + bin1 = bin(int(hash1, 16))[2:].zfill(len(hash1) * 4) + bin2 = bin(int(hash2, 16))[2:].zfill(len(hash2) * 4) + + return sum(c1 != c2 for c1, c2 in zip(bin1, bin2)) + + def similarity_score(self, hash1: str, hash2: str) -> float: + """ + Compute similarity score (0.0 to 1.0) between two hashes. + 1.0 = identical, 0.0 = completely different. + """ + distance = self.compute_hamming_distance(hash1, hash2) + max_distance = len(hash1) * 4 # Each hex char = 4 bits + return 1.0 - (distance / max_distance) + + async def find_similar( + self, target_hash: str, hash_type: str = "phash", threshold: int = 10 + ) -> List[Dict]: + """ + Find images with similar hashes in the database. + + Args: + target_hash: Hash to compare against + hash_type: Type of hash (phash, dhash, ahash) + threshold: Maximum Hamming distance to consider similar + + Returns: + List of dicts with analysis_id, hash, hamming_distance, similarity_score + """ + db = self.frame.database if self.frame else None + if not db: + logger.warning("Database not available for similarity search") + return [] + + # Get all hashes from database + try: + rows = await db.fetch_all( + f"SELECT id, {hash_type} FROM arkham_media_analyses WHERE {hash_type} IS NOT NULL" + ) + except Exception as e: + logger.warning("Failed to query hashes from database", error=str(e)) + return [] + + similar = [] + for row in rows: + hash_value = row.get(hash_type) or row.get(hash_type.upper()) + if hash_value: + try: + distance = self.compute_hamming_distance(target_hash, hash_value) + if distance <= threshold: + similar.append({ + "analysis_id": row.get("id"), + "hash": hash_value, + "hamming_distance": distance, + "similarity_score": self.similarity_score(target_hash, hash_value), + }) + except Exception: + continue + + # Sort by distance (most similar first) + similar.sort(key=lambda x: x["hamming_distance"]) + return similar diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/sun_position.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/sun_position.py new file mode 100644 index 0000000..a95b4ee --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/services/sun_position.py @@ -0,0 +1,195 @@ +""" +Sun position verification service. +Calculates expected shadow direction based on GPS and timestamp. +""" + +from typing import Dict, Any +from datetime import datetime, timezone +import math + +import structlog + +logger = structlog.get_logger() + +try: + from pysolar.solar import get_altitude, get_azimuth + PYSOLAR_AVAILABLE = True +except ImportError: + PYSOLAR_AVAILABLE = False + logger.info("pysolar not installed, sun position verification disabled") + + +class SunPositionService: + """ + Verify shadow consistency using sun position calculations. + + Uses GPS coordinates and timestamp from EXIF to calculate + where the sun would have been, and therefore where shadows + should point. + """ + + def __init__(self, frame): + self.frame = frame + + def is_available(self) -> bool: + """Check if sun position calculation is available.""" + return PYSOLAR_AVAILABLE + + async def calculate_sun_position( + self, + latitude: float, + longitude: float, + dt: datetime, + ) -> Dict[str, Any]: + """ + Calculate sun position for given coordinates and time. + + Args: + latitude: Decimal degrees (positive = North) + longitude: Decimal degrees (positive = East) + dt: Timezone-aware datetime + + Returns: + Dict with sun altitude, azimuth, and shadow direction + """ + if not PYSOLAR_AVAILABLE: + return {"error": "pysolar not installed", "success": False} + + # Ensure datetime is timezone-aware + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + try: + # Get sun position + altitude = get_altitude(latitude, longitude, dt) + azimuth = get_azimuth(latitude, longitude, dt) + + # Shadow direction is opposite to sun azimuth + shadow_direction = (azimuth + 180) % 360 + + # Calculate expected shadow length ratio (for vertical objects) + # shadow_length = object_height / tan(altitude) + if altitude > 0: + shadow_length_ratio = 1.0 / math.tan(math.radians(altitude)) + else: + shadow_length_ratio = float("inf") # Sun below horizon + + return { + "success": True, + "latitude": latitude, + "longitude": longitude, + "datetime": dt.isoformat(), + "sun_altitude": round(altitude, 2), + "sun_azimuth": round(azimuth, 2), + "expected_shadow_direction": round(shadow_direction, 2), + "shadow_length_ratio": round(shadow_length_ratio, 2) if shadow_length_ratio != float("inf") else None, + "sun_above_horizon": altitude > 0, + "interpretation": self._interpret_position(altitude, azimuth), + } + + except Exception as e: + logger.error("Sun position calculation failed", error=str(e)) + return {"success": False, "error": str(e)} + + def _interpret_position(self, altitude: float, azimuth: float) -> str: + """Generate human-readable interpretation of sun position.""" + if altitude < 0: + return "Sun is below the horizon - no direct sunlight possible" + + # Time of day approximation + if altitude < 10: + time_desc = "very low (sunrise/sunset)" + elif altitude < 30: + time_desc = "low (morning/evening)" + elif altitude < 60: + time_desc = "moderate (mid-morning/afternoon)" + else: + time_desc = "high (midday)" + + # Direction + if 337.5 <= azimuth or azimuth < 22.5: + direction = "North" + elif 22.5 <= azimuth < 67.5: + direction = "Northeast" + elif 67.5 <= azimuth < 112.5: + direction = "East" + elif 112.5 <= azimuth < 157.5: + direction = "Southeast" + elif 157.5 <= azimuth < 202.5: + direction = "South" + elif 202.5 <= azimuth < 247.5: + direction = "Southwest" + elif 247.5 <= azimuth < 292.5: + direction = "West" + else: + direction = "Northwest" + + return f"Sun is {time_desc}, positioned to the {direction}. Shadows should point roughly opposite." + + async def verify_from_exif( + self, + gps_data: Dict[str, Any], + timestamp_data: Dict[str, Any], + ) -> Dict[str, Any]: + """ + Calculate sun position from EXIF GPS and timestamp data. + + Args: + gps_data: GPS data from EXIF (latitude, longitude) + timestamp_data: Timestamp data from EXIF (datetime_original, etc.) + + Returns: + Sun position calculation or error + """ + # Get coordinates + latitude = gps_data.get("latitude") + longitude = gps_data.get("longitude") + + if latitude is None or longitude is None: + return { + "success": False, + "error": "GPS coordinates not available in EXIF", + } + + # Get timestamp + dt_str = ( + timestamp_data.get("datetime_original") + or timestamp_data.get("datetime_digitized") + or timestamp_data.get("datetime_modified") + ) + + if not dt_str: + return { + "success": False, + "error": "No timestamp available in EXIF", + } + + # Parse timestamp (EXIF format is typically "YYYY:MM:DD HH:MM:SS") + try: + dt_str = dt_str.replace(":", "-", 2) # Fix date separators + dt = datetime.fromisoformat(dt_str) + + # Check for GPS timestamp for more precision + gps_time = timestamp_data.get("gps_timestamp") + gps_date = timestamp_data.get("gps_datestamp") + if gps_time and gps_date: + # GPS timestamps are always UTC + dt = dt.replace(tzinfo=timezone.utc) + else: + # Assume local time, but note uncertainty + dt = dt.replace(tzinfo=timezone.utc) # Treat as UTC for calculation + + except Exception as e: + return { + "success": False, + "error": f"Could not parse timestamp: {dt_str} ({e})", + } + + result = await self.calculate_sun_position(latitude, longitude, dt) + result["timestamp_source"] = "exif" + result["timezone_note"] = ( + "Timezone assumed UTC if not specified in EXIF. " + "Actual local time may differ, affecting accuracy." + ) + + return result diff --git a/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/shard.py b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/shard.py new file mode 100644 index 0000000..12535d9 --- /dev/null +++ b/packages/arkham-shard-media-forensics/arkham_shard_media_forensics/shard.py @@ -0,0 +1,1913 @@ +"""Media Forensics Shard - Media metadata extraction and forensic analysis.""" + +import base64 +import json +import logging +import os +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib.parse import quote, urlencode + +from arkham_frame.shard_interface import ArkhamShard + + +def _make_json_safe(obj: Any) -> Any: + """Recursively convert an object to be JSON-serializable.""" + if obj is None: + return None + if isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, bytes): + try: + return obj.decode("utf-8", errors="replace") + except Exception: + return f"" + if isinstance(obj, (list, tuple)): + return [_make_json_safe(v) for v in obj] + if isinstance(obj, dict): + return {str(k): _make_json_safe(v) for k, v in obj.items()} + # Handle numpy types + try: + import numpy as np + if isinstance(obj, np.bool_): + return bool(obj) + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + if isinstance(obj, np.ndarray): + return obj.tolist() + except ImportError: + pass + # Handle IFDRational, Fraction, and other numeric types + try: + return float(obj) + except (TypeError, ValueError): + pass + # Fall back to string representation + return str(obj) + +from .api import init_api, router +from .models import ( + AnalysisStats, + ELAResult, + IntegrityStatus, + MediaAnalysis, + SimilarImage, + SunVerification, +) + +logger = logging.getLogger(__name__) + + +class MediaForensicsShard(ArkhamShard): + """ + Media Forensics shard for ArkhamFrame. + + Provides media metadata extraction and forensic analysis for images: + - EXIF/XMP metadata extraction + - Perceptual hashing (pHash, dHash, aHash) for image similarity + - C2PA Content Credentials parsing and verification + - Error Level Analysis (ELA) visualization + - Sun position verification for shadow analysis + + All features work fully offline (air-gap compatible). + """ + + name = "media-forensics" + version = "0.1.0" + description = "Media metadata extraction and forensic analysis" + + def __init__(self): + super().__init__() + self._frame = None + self._db = None + self._event_bus = None + self._storage = None + + # Services (lazy-loaded) + self.exif_extractor = None + self.hash_service = None + self.c2pa_parser = None + self.ela_analyzer = None + self.sun_position = None + + async def initialize(self, frame) -> None: + """ + Initialize the shard with Frame services. + + Args: + frame: The ArkhamFrame instance + """ + self._frame = frame + self._db = frame.database + self._event_bus = frame.get_service("events") + self._storage = frame.get_service("storage") + + logger.info("Initializing Media Forensics Shard...") + + # Create database schema + await self._create_schema() + + # Initialize services + self._init_services() + + # Initialize API with shard reference + init_api(self) + + # Subscribe to events + if self._event_bus: + await self._event_bus.subscribe("document.ingested", self._handle_document_ingested) + logger.info("Subscribed to document.ingested event") + + # Register on app state for API access + if hasattr(frame, "app") and frame.app: + frame.app.state.media_forensics_shard = self + logger.debug("Media Forensics Shard registered on app.state") + + logger.info("Media Forensics Shard initialized") + + def _init_services(self) -> None: + """Initialize service classes (lazy import to avoid import errors).""" + try: + from .services.exif_extractor import ExifExtractor + self.exif_extractor = ExifExtractor(self._frame) + logger.debug("ExifExtractor initialized") + except ImportError as e: + logger.warning(f"ExifExtractor not available: {e}") + + try: + from .services.perceptual_hash import PerceptualHashService + self.hash_service = PerceptualHashService(self._frame) + logger.debug("PerceptualHashService initialized") + except ImportError as e: + logger.warning(f"PerceptualHashService not available: {e}") + + try: + from .services.c2pa_parser import C2PAParser + self.c2pa_parser = C2PAParser(self._frame) + logger.debug("C2PAParser initialized") + except ImportError as e: + logger.warning(f"C2PAParser not available: {e}") + + try: + from .services.ela_analyzer import ELAAnalyzer + self.ela_analyzer = ELAAnalyzer(self._frame) + logger.debug("ELAAnalyzer initialized") + except ImportError as e: + logger.warning(f"ELAAnalyzer not available: {e}") + + try: + from .services.sun_position import SunPositionService + self.sun_position = SunPositionService(self._frame) + logger.debug("SunPositionService initialized") + except ImportError as e: + logger.warning(f"SunPositionService not available: {e}") + + async def shutdown(self) -> None: + """Clean up shard resources.""" + logger.info("Shutting down Media Forensics Shard...") + + # Unsubscribe from events + if self._event_bus: + self._event_bus.unsubscribe("document.ingested") + + # Clear services + self.exif_extractor = None + self.hash_service = None + self.c2pa_parser = None + self.ela_analyzer = None + self.sun_position = None + + logger.info("Media Forensics Shard shutdown complete") + + def get_routes(self): + """Return FastAPI router for this shard.""" + return router + + # =========================================== + # Database Schema + # =========================================== + + async def _create_schema(self) -> None: + """Create database tables for media forensics.""" + if not self._db: + logger.warning("Database not available - persistence disabled") + return + + try: + # Main analyses table + await self._db.execute(""" + CREATE TABLE IF NOT EXISTS arkham_media_analyses ( + id TEXT PRIMARY KEY, + document_id TEXT, -- Nullable for direct file analysis + tenant_id UUID, + + -- File info + filename TEXT, -- Original filename + file_path TEXT, -- Full path to file for ELA and other operations + file_type TEXT, + file_size INTEGER, + width INTEGER, + height INTEGER, + + -- Cryptographic hashes + sha256 TEXT, + md5 TEXT, + + -- Perceptual hashes + phash TEXT, + dhash TEXT, + ahash TEXT, + + -- EXIF data (JSON) + exif_data TEXT DEFAULT '{}', + + -- Camera info (extracted from EXIF) + camera_make TEXT, + camera_model TEXT, + software TEXT, + + -- Timestamps (extracted from EXIF) + datetime_original TEXT, + datetime_digitized TEXT, + datetime_modified TEXT, + + -- GPS (extracted from EXIF) + gps_latitude REAL, + gps_longitude REAL, + gps_altitude REAL, + + -- C2PA data (JSON) + c2pa_data TEXT DEFAULT '{}', + has_c2pa INTEGER DEFAULT 0, + c2pa_signer TEXT, + c2pa_timestamp TEXT, + + -- Analysis results + warnings TEXT DEFAULT '[]', + anomalies TEXT DEFAULT '[]', + integrity_status TEXT DEFAULT 'unknown', + confidence_score REAL DEFAULT 0.0, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Add file_path column if not exists (for existing databases) + try: + await self._db.execute(""" + ALTER TABLE arkham_media_analyses ADD COLUMN IF NOT EXISTS file_path TEXT + """) + except Exception: + pass # Column might already exist or not supported + + # Similar images table (for perceptual hash matches) + await self._db.execute(""" + CREATE TABLE IF NOT EXISTS arkham_media_similar ( + id TEXT PRIMARY KEY, + source_analysis_id TEXT NOT NULL, + target_analysis_id TEXT NOT NULL, + + hash_type TEXT NOT NULL, + hamming_distance INTEGER NOT NULL, + similarity_score REAL NOT NULL, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (source_analysis_id) REFERENCES arkham_media_analyses(id) ON DELETE CASCADE, + FOREIGN KEY (target_analysis_id) REFERENCES arkham_media_analyses(id) ON DELETE CASCADE, + UNIQUE(source_analysis_id, target_analysis_id, hash_type) + ) + """) + + # ELA results table (generated on demand) + await self._db.execute(""" + CREATE TABLE IF NOT EXISTS arkham_media_ela ( + id TEXT PRIMARY KEY, + analysis_id TEXT NOT NULL, + + quality INTEGER DEFAULT 95, + ela_image_path TEXT, + + -- Analysis of ELA result + uniform_regions TEXT DEFAULT '[]', + anomalous_regions TEXT DEFAULT '[]', + interpretation TEXT, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (analysis_id) REFERENCES arkham_media_analyses(id) ON DELETE CASCADE + ) + """) + + # Sun position verification table + await self._db.execute(""" + CREATE TABLE IF NOT EXISTS arkham_media_sun_verification ( + id TEXT PRIMARY KEY, + analysis_id TEXT NOT NULL, + + -- Input parameters + claimed_datetime TEXT, + latitude REAL, + longitude REAL, + + -- Calculated sun position + sun_altitude REAL, + sun_azimuth REAL, + expected_shadow_direction REAL, + + -- Verification result + verification_status TEXT, + notes TEXT, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (analysis_id) REFERENCES arkham_media_analyses(id) ON DELETE CASCADE + ) + """) + + # Create indexes + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_media_analyses_document ON arkham_media_analyses(document_id)" + ) + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_media_analyses_tenant ON arkham_media_analyses(tenant_id)" + ) + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_media_analyses_phash ON arkham_media_analyses(phash)" + ) + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_media_analyses_integrity ON arkham_media_analyses(integrity_status)" + ) + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_media_similar_source ON arkham_media_similar(source_analysis_id)" + ) + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_media_ela_analysis ON arkham_media_ela(analysis_id)" + ) + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_media_sun_analysis ON arkham_media_sun_verification(analysis_id)" + ) + + # Migration: add tenant_id column if missing + await self._db.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'arkham_media_analyses' + AND column_name = 'tenant_id' + ) THEN + ALTER TABLE arkham_media_analyses ADD COLUMN tenant_id UUID; + END IF; + END $$; + """) + + # Migration: make document_id nullable for direct file analysis + try: + await self._db.execute(""" + ALTER TABLE arkham_media_analyses + ALTER COLUMN document_id DROP NOT NULL; + """) + except Exception: + pass # Column may already be nullable + + # Migration: add filename column if it doesn't exist + try: + await self._db.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'arkham_media_analyses' + AND column_name = 'filename' + ) THEN + ALTER TABLE arkham_media_analyses ADD COLUMN filename TEXT; + END IF; + END $$; + """) + except Exception: + pass # Column may already exist + + logger.info("Media forensics schema created successfully") + + except Exception as e: + logger.error(f"Failed to create schema: {e}") + + # =========================================== + # Event Handlers + # =========================================== + + async def _handle_document_ingested(self, event_data: Dict) -> None: + """Auto-analyze image documents when ingested.""" + doc_id = event_data.get("document_id") + doc_type = event_data.get("doc_type", "").lower() + + # Only process image types + image_types = ["image/jpeg", "image/png", "image/tiff", "image/webp", "image/heic", "image/gif"] + if any(t in doc_type for t in image_types): + try: + await self.analyze_document(doc_id) + logger.info(f"Auto-analyzed document {doc_id}") + except Exception as e: + logger.warning(f"Auto-analysis failed for {doc_id}: {e}") + + # =========================================== + # Public API Methods + # =========================================== + + async def analyze_document(self, document_id: str) -> Dict[str, Any]: + """ + Perform full media analysis on a document. + + Extracts EXIF, computes hashes, parses C2PA. + + Args: + document_id: Document ID to analyze + + Returns: + Analysis results dictionary + + Raises: + FileNotFoundError: If document or file not found + ValueError: If document is not an image + """ + if not self._db: + raise RuntimeError("Database service not available") + + # Get document from frame + doc = await self._db.fetch_one( + "SELECT id, path, doc_type FROM arkham_frame.documents WHERE id = :id", + {"id": document_id}, + ) + + if not doc: + raise FileNotFoundError(f"Document {document_id} not found") + + file_path = Path(doc["path"]) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + # Create analysis record + analysis_id = str(uuid.uuid4()) + tenant_id = self.get_tenant_id_or_none() + + # Extract metadata + exif_data = {} + if self.exif_extractor: + exif_data = await self.exif_extractor.extract_all(file_path) + + # Compute hashes + hashes = {} + if self.hash_service: + hashes = await self.hash_service.compute_all_hashes(file_path) + + # Parse C2PA + c2pa_data = {} + c2pa_interpretation = {} + if self.c2pa_parser: + c2pa_data = await self.c2pa_parser.parse(file_path) or {} + c2pa_interpretation = self.c2pa_parser.interpret_c2pa(c2pa_data) + + # Compile warnings + warnings = exif_data.get("warnings", []) + if c2pa_interpretation.get("is_ai_generated"): + warnings.append(f"AI_GENERATED: C2PA indicates AI generation by {c2pa_data.get('signer')}") + + # Determine integrity status + if c2pa_data and c2pa_data.get("has_c2pa") and c2pa_data.get("signature_valid"): + integrity_status = "verified" + elif warnings: + integrity_status = "flagged" + else: + integrity_status = "unverified" + + # Calculate confidence score + confidence_score = 0.8 if c2pa_data and c2pa_data.get("has_c2pa") else 0.5 + + # Save to database + await self._db.execute( + """ + INSERT INTO arkham_media_analyses ( + id, document_id, tenant_id, filename, file_type, + width, height, + sha256, md5, phash, dhash, ahash, + exif_data, + camera_make, camera_model, software, + datetime_original, datetime_digitized, datetime_modified, + gps_latitude, gps_longitude, gps_altitude, + c2pa_data, has_c2pa, c2pa_signer, c2pa_timestamp, + warnings, integrity_status, confidence_score, + created_at, updated_at + ) VALUES ( + :id, :document_id, :tenant_id, :filename, :file_type, + :width, :height, + :sha256, :md5, :phash, :dhash, :ahash, + :exif_data, + :camera_make, :camera_model, :software, + :datetime_original, :datetime_digitized, :datetime_modified, + :gps_latitude, :gps_longitude, :gps_altitude, + :c2pa_data, :has_c2pa, :c2pa_signer, :c2pa_timestamp, + :warnings, :integrity_status, :confidence_score, + :created_at, :updated_at + ) + ON CONFLICT (id) DO UPDATE SET + exif_data = EXCLUDED.exif_data, + warnings = EXCLUDED.warnings, + integrity_status = EXCLUDED.integrity_status, + updated_at = NOW() + """, + { + "id": analysis_id, + "document_id": document_id, + "tenant_id": str(tenant_id) if tenant_id else None, + "filename": file_path.name, + "file_type": doc["doc_type"], + "width": exif_data.get("basic", {}).get("width"), + "height": exif_data.get("basic", {}).get("height"), + "sha256": hashes.get("sha256"), + "md5": hashes.get("md5"), + "phash": hashes.get("phash"), + "dhash": hashes.get("dhash"), + "ahash": hashes.get("ahash"), + "exif_data": json.dumps(_make_json_safe(exif_data.get("exif", {}))), + "camera_make": exif_data.get("camera", {}).get("make"), + "camera_model": exif_data.get("camera", {}).get("model"), + "software": exif_data.get("camera", {}).get("software"), + "datetime_original": exif_data.get("timestamps", {}).get("datetime_original"), + "datetime_digitized": exif_data.get("timestamps", {}).get("datetime_digitized"), + "datetime_modified": exif_data.get("timestamps", {}).get("datetime_modified"), + "gps_latitude": exif_data.get("gps", {}).get("latitude"), + "gps_longitude": exif_data.get("gps", {}).get("longitude"), + "gps_altitude": exif_data.get("gps", {}).get("altitude"), + "c2pa_data": json.dumps(_make_json_safe(c2pa_data)) if c2pa_data else "{}", + "has_c2pa": 1 if c2pa_data and c2pa_data.get("has_c2pa") else 0, + "c2pa_signer": c2pa_data.get("signer") if c2pa_data else None, + "c2pa_timestamp": c2pa_data.get("timestamp") if c2pa_data else None, + "warnings": json.dumps(warnings), + "integrity_status": integrity_status, + "confidence_score": confidence_score, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + }, + ) + + # Emit event + if self._event_bus: + await self._event_bus.emit( + "media.metadata.extracted", + { + "document_id": document_id, + "analysis_id": analysis_id, + "has_exif": bool(exif_data.get("exif")), + "has_gps": bool(exif_data.get("gps", {}).get("latitude")), + "has_c2pa": bool(c2pa_data and c2pa_data.get("has_c2pa")), + "warnings": warnings, + "integrity_status": integrity_status, + }, + source="media-forensics", + ) + + # Find similar images + if self.hash_service and hashes.get("phash"): + similar = await self.hash_service.find_similar( + hashes["phash"], hash_type="phash", threshold=10 + ) + # Filter out self + similar = [s for s in similar if s["analysis_id"] != analysis_id] + + if similar and self._event_bus: + await self._event_bus.emit( + "media.similar.found", + { + "source_analysis_id": analysis_id, + "similar_count": len(similar), + "matches": [s["analysis_id"] for s in similar[:5]], + }, + source="media-forensics", + ) + + return { + "analysis_id": analysis_id, + "document_id": document_id, + "exif": exif_data, + "hashes": hashes, + "c2pa": c2pa_data, + "c2pa_interpretation": c2pa_interpretation, + "warnings": warnings, + "integrity_status": integrity_status, + } + + async def analyze_file( + self, + file_path: Path, + filename: str, + run_ela: bool = False, + document_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Analyze an image file directly without requiring a document record. + + This is the core analysis method that works on raw files. + + Args: + file_path: Path to the image file + filename: Original filename for display + run_ela: Whether to also run ELA analysis + document_id: Optional document ID to link to + + Returns: + Analysis results dictionary + """ + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + # Create analysis record + analysis_id = str(uuid.uuid4()) + tenant_id = self.get_tenant_id_or_none() + + # Get file info + file_size = file_path.stat().st_size + file_type = file_path.suffix.lower().lstrip(".") + + # Extract EXIF metadata + exif_data = {} + if self.exif_extractor: + try: + exif_data = await self.exif_extractor.extract_all(file_path) + logger.info(f"EXIF extraction complete: {len(exif_data)} keys") + except Exception as e: + logger.warning(f"EXIF extraction failed: {e}") + exif_data = {"error": str(e), "warnings": [f"EXIF extraction failed: {e}"]} + + # Compute perceptual hashes + hashes = {} + if self.hash_service: + try: + hashes = await self.hash_service.compute_all_hashes(file_path) + logger.info(f"Hash computation complete: {list(hashes.keys())}") + except Exception as e: + logger.warning(f"Hash computation failed: {e}") + hashes = {"error": str(e)} + + # Parse C2PA content credentials + c2pa_data = {} + c2pa_interpretation = {} + if self.c2pa_parser: + try: + c2pa_data = await self.c2pa_parser.parse(file_path) or {} + c2pa_interpretation = self.c2pa_parser.interpret_c2pa(c2pa_data) + logger.info(f"C2PA parsing complete: has_c2pa={c2pa_data.get('has_c2pa')}") + except Exception as e: + logger.warning(f"C2PA parsing failed: {e}") + c2pa_data = {"error": str(e)} + + # Compile warnings from all sources + warnings = list(exif_data.get("warnings", [])) + if c2pa_interpretation.get("is_ai_generated"): + warnings.append(f"AI_GENERATED: C2PA indicates AI generation by {c2pa_data.get('signer')}") + + # Determine integrity status + if c2pa_data and c2pa_data.get("has_c2pa") and c2pa_data.get("signature_valid"): + integrity_status = "verified" + elif warnings: + integrity_status = "flagged" + else: + integrity_status = "unverified" + + # Calculate confidence score + confidence_score = 0.8 if c2pa_data and c2pa_data.get("has_c2pa") else 0.5 + + # ELA analysis if requested + ela_result = None + if run_ela and self.ela_analyzer: + try: + ela_result = await self.ela_analyzer.analyze(file_path) + logger.info("ELA analysis complete") + except Exception as e: + logger.warning(f"ELA analysis failed: {e}") + ela_result = {"error": str(e)} + + # Save to database if available + if self._db: + try: + await self._db.execute( + """ + INSERT INTO arkham_media_analyses ( + id, document_id, tenant_id, filename, file_path, file_type, file_size, + width, height, + sha256, md5, phash, dhash, ahash, + exif_data, + camera_make, camera_model, software, + datetime_original, datetime_digitized, datetime_modified, + gps_latitude, gps_longitude, gps_altitude, + c2pa_data, has_c2pa, c2pa_signer, c2pa_timestamp, + warnings, integrity_status, confidence_score, + created_at, updated_at + ) VALUES ( + :id, :document_id, :tenant_id, :filename, :file_path, :file_type, :file_size, + :width, :height, + :sha256, :md5, :phash, :dhash, :ahash, + :exif_data, + :camera_make, :camera_model, :software, + :datetime_original, :datetime_digitized, :datetime_modified, + :gps_latitude, :gps_longitude, :gps_altitude, + :c2pa_data, :has_c2pa, :c2pa_signer, :c2pa_timestamp, + :warnings, :integrity_status, :confidence_score, + :created_at, :updated_at + ) + """, + { + "id": analysis_id, + "document_id": document_id, + "tenant_id": str(tenant_id) if tenant_id else None, + "filename": file_path.name, + "file_path": str(file_path.absolute()), + "file_type": file_type, + "file_size": file_size, + "width": exif_data.get("basic", {}).get("width"), + "height": exif_data.get("basic", {}).get("height"), + "sha256": hashes.get("sha256"), + "md5": hashes.get("md5"), + "phash": hashes.get("phash"), + "dhash": hashes.get("dhash"), + "ahash": hashes.get("ahash"), + "exif_data": json.dumps(_make_json_safe(exif_data.get("exif", {}))), + "camera_make": exif_data.get("camera", {}).get("make"), + "camera_model": exif_data.get("camera", {}).get("model"), + "software": exif_data.get("camera", {}).get("software"), + "datetime_original": exif_data.get("timestamps", {}).get("datetime_original"), + "datetime_digitized": exif_data.get("timestamps", {}).get("datetime_digitized"), + "datetime_modified": exif_data.get("timestamps", {}).get("datetime_modified"), + "gps_latitude": exif_data.get("gps", {}).get("latitude"), + "gps_longitude": exif_data.get("gps", {}).get("longitude"), + "gps_altitude": exif_data.get("gps", {}).get("altitude"), + "c2pa_data": json.dumps(_make_json_safe(c2pa_data)) if c2pa_data else "{}", + "has_c2pa": 1 if c2pa_data and c2pa_data.get("has_c2pa") else 0, + "c2pa_signer": c2pa_data.get("signer") if c2pa_data else None, + "c2pa_timestamp": c2pa_data.get("timestamp") if c2pa_data else None, + "warnings": json.dumps(warnings), + "integrity_status": integrity_status, + "confidence_score": confidence_score, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + }, + ) + logger.info(f"Saved analysis {analysis_id} to database") + except Exception as e: + logger.error(f"Failed to save analysis to database: {e}") + + # Emit event + if self._event_bus: + try: + await self._event_bus.emit( + "media.file.analyzed", + { + "analysis_id": analysis_id, + "filename": filename, + "file_type": file_type, + "has_exif": bool(exif_data.get("exif")), + "has_gps": bool(exif_data.get("gps", {}).get("latitude")), + "has_c2pa": bool(c2pa_data and c2pa_data.get("has_c2pa")), + "warnings_count": len(warnings), + "integrity_status": integrity_status, + }, + source="media-forensics", + ) + except Exception as e: + logger.warning(f"Failed to emit event: {e}") + + # Build result + result = { + "analysis_id": analysis_id, + "filename": filename, + "file_type": file_type, + "file_size": file_size, + "exif": exif_data, + "hashes": hashes, + "c2pa": c2pa_data, + "c2pa_interpretation": c2pa_interpretation, + "warnings": warnings, + "integrity_status": integrity_status, + "confidence_score": confidence_score, + } + + if ela_result: + result["ela"] = ela_result + + return result + + async def get_analysis(self, analysis_id: str) -> Optional[Dict[str, Any]]: + """Get a specific analysis by ID.""" + if not self._db: + return None + + row = await self._db.fetch_one( + "SELECT * FROM arkham_media_analyses WHERE id = :id", + {"id": analysis_id} + ) + + if not row: + return None + + return self._row_to_analysis_dict(row) + + async def get_analysis_by_document(self, document_id: str) -> Optional[Dict[str, Any]]: + """Get analysis for a specific document.""" + if not self._db: + return None + + row = await self._db.fetch_one( + "SELECT * FROM arkham_media_analyses WHERE document_id = :document_id ORDER BY created_at DESC LIMIT 1", + {"document_id": document_id} + ) + + if not row: + return None + + return self._row_to_analysis_dict(row) + + async def list_analyses( + self, + limit: int = 50, + offset: int = 0, + status: Optional[str] = None, + verification_status: Optional[str] = None, + has_c2pa: Optional[bool] = None, + has_warnings: Optional[bool] = None, + has_findings: Optional[bool] = None, + integrity_status: Optional[str] = None, + doc_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """List media analyses with optional filtering.""" + if not self._db: + return [] + + # Build query with filters + query = "SELECT * FROM arkham_media_analyses WHERE 1=1" + params: Dict[str, Any] = {"limit": limit, "offset": offset} + + # Add tenant filtering + tenant_id = self.get_tenant_id_or_none() + if tenant_id: + query += " AND tenant_id = :tenant_id" + params["tenant_id"] = str(tenant_id) + + # Status filters (map to integrity_status for now as we don't have a separate status column) + if status: + # Status is mapped to integrity_status for compatibility + pass # Status not yet implemented as a separate column + + if verification_status: + query += " AND integrity_status = :verification_status" + params["verification_status"] = verification_status + + if has_c2pa is not None: + query += " AND has_c2pa = :has_c2pa" + params["has_c2pa"] = 1 if has_c2pa else 0 + + if has_warnings is not None: + if has_warnings: + query += " AND warnings != '[]'" + else: + query += " AND warnings = '[]'" + + if has_findings is not None: + # has_findings maps to has_warnings for now + if has_findings: + query += " AND warnings != '[]'" + else: + query += " AND warnings = '[]'" + + if integrity_status: + query += " AND integrity_status = :integrity_status" + params["integrity_status"] = integrity_status + + if doc_id: + query += " AND document_id = :doc_id" + params["doc_id"] = doc_id + + query += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + + rows = await self._db.fetch_all(query, params) + return [self._row_to_analysis_dict(row) for row in rows] + + async def get_analysis_count(self) -> int: + """Get total analysis count.""" + if not self._db: + return 0 + + query = "SELECT COUNT(*) as count FROM arkham_media_analyses WHERE 1=1" + params: Dict[str, Any] = {} + + tenant_id = self.get_tenant_id_or_none() + if tenant_id: + query += " AND tenant_id = :tenant_id" + params["tenant_id"] = str(tenant_id) + + row = await self._db.fetch_one(query, params) + return row["count"] if row else 0 + + async def generate_ela( + self, + analysis_id: str, + quality: int = 95, + scale: int = 15, + ) -> Dict[str, Any]: + """ + Generate Error Level Analysis for an analysis. + + Args: + analysis_id: Analysis ID + quality: JPEG quality for resave (90-95 recommended) + scale: Multiplier for error visualization (10-20 recommended) + + Returns: + ELA result with base64 image and interpretation + """ + if not self.ela_analyzer: + return {"success": False, "error": "ELA analyzer not available"} + + analysis = await self.get_analysis(analysis_id) + if not analysis: + return {"success": False, "error": "Analysis not found"} + + # Get file path - first try from analysis record, then from documents table + file_path = None + + # Check if file_path is stored directly on the analysis + if analysis.get("file_path"): + file_path = Path(analysis["file_path"]) + + # Fall back to documents table if we have a document_id + if (not file_path or not file_path.exists()) and analysis.get("document_id"): + doc = await self._db.fetch_one( + "SELECT path FROM arkham_frame.documents WHERE id = :id", + {"id": analysis["document_id"]} + ) + if doc and doc.get("path"): + file_path = Path(doc["path"]) + + if not file_path: + return {"success": False, "error": "File path not found in analysis or documents"} + + if not file_path.exists(): + return {"success": False, "error": f"File not found: {file_path}"} + + # Generate ELA + result = await self.ela_analyzer.analyze(file_path, quality=quality, scale=scale) + + # Save to database if successful + if result.get("success") and self._db: + ela_id = str(uuid.uuid4()) + await self._db.execute( + """ + INSERT INTO arkham_media_ela (id, analysis_id, quality, interpretation, created_at) + VALUES (:id, :analysis_id, :quality, :interpretation, :created_at) + """, + { + "id": ela_id, + "analysis_id": analysis_id, + "quality": quality, + "interpretation": json.dumps(result.get("interpretation", {})), + "created_at": datetime.utcnow().isoformat(), + } + ) + + # Emit event + if self._event_bus: + await self._event_bus.emit( + "media.ela.generated", + { + "analysis_id": analysis_id, + "ela_id": ela_id, + "quality": quality, + }, + source="media-forensics", + ) + + return result + + async def find_similar_images( + self, + analysis_id: str, + hash_type: str = "phash", + threshold: int = 10, + ) -> List[Dict[str, Any]]: + """ + Find images similar to a given analysis using perceptual hashing. + + Args: + analysis_id: Source analysis ID + hash_type: Type of hash to use (phash, dhash, ahash) + threshold: Maximum Hamming distance + + Returns: + List of similar images + """ + if not self.hash_service: + return [] + + analysis = await self.get_analysis(analysis_id) + if not analysis: + return [] + + target_hash = analysis.get(hash_type) + if not target_hash: + return [] + + similar = await self.hash_service.find_similar(target_hash, hash_type=hash_type, threshold=threshold) + + # Filter out self + similar = [s for s in similar if s["analysis_id"] != analysis_id] + + return similar + + async def reverse_image_search( + self, analysis_id: str, base_url: Optional[str] = None + ) -> Dict[str, Any]: + """ + Perform reverse image search using external web APIs or generate search URLs. + + This requires network access for API-based searches. + Always generates clickable search URLs for manual searching. + + Args: + analysis_id: Source analysis ID + base_url: Base URL for constructing image URL (e.g., http://localhost:8100) + + Returns: + Dict containing search URLs and any API-based results + """ + analysis = await self.get_analysis(analysis_id) + if not analysis: + return {"search_urls": [], "api_results": [], "error": "Analysis not found"} + + file_path = analysis.get("file_path") + if not file_path: + return {"search_urls": [], "api_results": [], "error": "No file path in analysis"} + + # Check if file exists + if not Path(file_path).exists(): + return {"search_urls": [], "api_results": [], "error": "Image file not found"} + + # Generate search URLs for manual reverse image search + search_urls = await self._generate_search_urls(analysis_id, file_path, base_url) + + # Try API-based searches if configured + api_results = [] + + # Check if we're in offline mode + offline_mode = False + if self._frame and hasattr(self._frame, "config"): + config = self._frame.config + offline_mode = getattr(config, "offline_mode", False) + + if not offline_mode: + try: + import aiohttp + + # Use TinEye API if configured (requires API key) + tineye_key = os.environ.get("TINEYE_API_KEY") + if tineye_key: + tineye_results = await self._tineye_search(file_path, tineye_key) + api_results.extend(tineye_results) + + # Use Google Vision API if configured (requires API key) + google_key = os.environ.get("GOOGLE_VISION_API_KEY") + if google_key: + google_results = await self._google_vision_search(file_path, google_key) + api_results.extend(google_results) + + # Use SerpAPI for Google Reverse Image Search (has free tier) + serpapi_key = os.environ.get("SERPAPI_KEY") + if serpapi_key and base_url: + serpapi_results = await self._serpapi_search(analysis_id, base_url, serpapi_key) + api_results.extend(serpapi_results) + + except ImportError: + logger.debug("aiohttp not installed - API-based reverse image search unavailable") + + return { + "search_urls": search_urls, + "api_results": api_results, + "has_api_keys": bool( + os.environ.get("TINEYE_API_KEY") or + os.environ.get("GOOGLE_VISION_API_KEY") or + os.environ.get("SERPAPI_KEY") + ), + } + + async def _generate_search_urls( + self, analysis_id: str, file_path: str, base_url: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Generate URLs for reverse image search engines.""" + search_urls = [] + + # If we have a base URL, generate a URL to the served image + image_url = None + if base_url: + # Use the image endpoint to serve the analysis image + image_url = f"{base_url}/api/media-forensics/image/{analysis_id}" + + # Google Images reverse search (supports URL-based search) + if image_url: + google_url = f"https://lens.google.com/uploadbyurl?url={quote(image_url)}" + search_urls.append({ + "engine": "Google Lens", + "url": google_url, + "icon": "search", + "description": "Search with Google Lens (URL-based)", + "type": "url_search", + }) + + # Google Images upload page (always available) + search_urls.append({ + "engine": "Google Images", + "url": "https://images.google.com/", + "icon": "image", + "description": "Upload image to Google Images", + "type": "upload_search", + "instructions": "Click the camera icon and upload the image", + }) + + # TinEye (upload-based) + search_urls.append({ + "engine": "TinEye", + "url": "https://tineye.com/", + "icon": "eye", + "description": "Upload image to TinEye", + "type": "upload_search", + "instructions": "Upload the image to search for matches", + }) + + # Yandex Images (supports URL-based search) + if image_url: + yandex_url = f"https://yandex.com/images/search?url={quote(image_url)}&rpt=imageview" + search_urls.append({ + "engine": "Yandex Images", + "url": yandex_url, + "icon": "globe", + "description": "Search with Yandex Images (URL-based)", + "type": "url_search", + }) + + # Yandex upload page (always available) + search_urls.append({ + "engine": "Yandex Images", + "url": "https://yandex.com/images/", + "icon": "globe", + "description": "Upload image to Yandex Images", + "type": "upload_search", + "instructions": "Click the camera icon and upload the image", + }) + + # Bing Visual Search (URL-based) + if image_url: + bing_url = f"https://www.bing.com/images/search?view=detailv2&iss=sbi&q=imgurl:{quote(image_url)}" + search_urls.append({ + "engine": "Bing Visual Search", + "url": bing_url, + "icon": "search", + "description": "Search with Bing Visual Search (URL-based)", + "type": "url_search", + }) + + return search_urls + + async def _serpapi_search( + self, analysis_id: str, base_url: str, api_key: str + ) -> List[Dict[str, Any]]: + """Search using SerpAPI Google Reverse Image Search.""" + try: + import aiohttp + + image_url = f"{base_url}/api/media-forensics/image/{analysis_id}" + + params = { + "engine": "google_reverse_image", + "image_url": image_url, + "api_key": api_key, + } + + async with aiohttp.ClientSession() as session: + async with session.get( + "https://serpapi.com/search", + params=params, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + results = [] + + # Process image results + for match in data.get("image_results", [])[:10]: + results.append({ + "url": match.get("link"), + "domain": match.get("source"), + "title": match.get("title", "SerpAPI Match"), + "thumbnail_url": match.get("thumbnail"), + "similarity_score": 0.75, + "source": "serpapi", + }) + + # Process inline images + for match in data.get("inline_images", [])[:5]: + if match.get("link"): + results.append({ + "url": match.get("link"), + "domain": match.get("source"), + "title": match.get("title", "SerpAPI Match"), + "thumbnail_url": match.get("thumbnail"), + "similarity_score": 0.7, + "source": "serpapi", + }) + + return results + else: + logger.warning(f"SerpAPI search returned status {resp.status}") + except Exception as e: + logger.warning(f"SerpAPI search failed: {e}") + return [] + + async def _tineye_search(self, file_path: str, api_key: str) -> List[Dict[str, Any]]: + """Search TinEye for matching images.""" + try: + import aiohttp + import base64 + + # Read and encode image + with open(file_path, "rb") as f: + image_data = base64.b64encode(f.read()).decode() + + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.tineye.com/rest/search/", + data={"image": image_data}, + headers={"x-api-key": api_key}, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + results = [] + for match in data.get("matches", [])[:10]: + results.append({ + "url": match.get("backlinks", [{}])[0].get("url"), + "domain": match.get("domain"), + "title": match.get("backlinks", [{}])[0].get("page_title", "TinEye Match"), + "thumbnail_url": match.get("image_url"), + "similarity_score": match.get("score", 50) / 100, + "source": "tineye", + }) + return results + except Exception as e: + logger.warning(f"TinEye search failed: {e}") + return [] + + async def _google_vision_search(self, file_path: str, api_key: str) -> List[Dict[str, Any]]: + """Search Google Vision for web detection.""" + try: + import aiohttp + import base64 + + # Read and encode image + with open(file_path, "rb") as f: + image_data = base64.b64encode(f.read()).decode() + + async with aiohttp.ClientSession() as session: + async with session.post( + f"https://vision.googleapis.com/v1/images:annotate?key={api_key}", + json={ + "requests": [{ + "image": {"content": image_data}, + "features": [{"type": "WEB_DETECTION", "maxResults": 10}], + }] + }, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + results = [] + web_detection = data.get("responses", [{}])[0].get("webDetection", {}) + + # Pages with matching images + for page in web_detection.get("pagesWithMatchingImages", [])[:10]: + results.append({ + "url": page.get("url"), + "domain": page.get("url", "").split("/")[2] if page.get("url") else None, + "title": page.get("pageTitle", "Google Vision Match"), + "thumbnail_url": page.get("fullMatchingImages", [{}])[0].get("url") + if page.get("fullMatchingImages") else None, + "similarity_score": 0.8, # Google doesn't provide score + "source": "google_vision", + }) + return results + except Exception as e: + logger.warning(f"Google Vision search failed: {e}") + return [] + + async def calculate_sun_position( + self, + latitude: float, + longitude: float, + dt: datetime, + ) -> Dict[str, Any]: + """Calculate sun position for given coordinates and time.""" + if not self.sun_position: + return {"success": False, "error": "Sun position service not available"} + + return await self.sun_position.calculate_sun_position(latitude, longitude, dt) + + async def get_sun_position_from_analysis(self, analysis_id: str) -> Dict[str, Any]: + """Calculate sun position from EXIF data of an analyzed image.""" + if not self.sun_position: + return {"success": False, "error": "Sun position service not available"} + + analysis = await self.get_analysis(analysis_id) + if not analysis: + return {"success": False, "error": "Analysis not found"} + + gps_data = { + "latitude": analysis.get("gps_latitude"), + "longitude": analysis.get("gps_longitude"), + } + + timestamp_data = { + "datetime_original": analysis.get("datetime_original"), + "datetime_digitized": analysis.get("datetime_digitized"), + "datetime_modified": analysis.get("datetime_modified"), + } + + result = await self.sun_position.verify_from_exif(gps_data, timestamp_data) + + # Save to database if successful + if result.get("success") and self._db: + sun_id = str(uuid.uuid4()) + await self._db.execute( + """ + INSERT INTO arkham_media_sun_verification + (id, analysis_id, claimed_datetime, latitude, longitude, + sun_altitude, sun_azimuth, expected_shadow_direction, + verification_status, notes, created_at) + VALUES + (:id, :analysis_id, :claimed_datetime, :latitude, :longitude, + :sun_altitude, :sun_azimuth, :expected_shadow_direction, + :verification_status, :notes, :created_at) + """, + { + "id": sun_id, + "analysis_id": analysis_id, + "claimed_datetime": result.get("datetime"), + "latitude": result.get("latitude"), + "longitude": result.get("longitude"), + "sun_altitude": result.get("sun_altitude"), + "sun_azimuth": result.get("sun_azimuth"), + "expected_shadow_direction": result.get("expected_shadow_direction"), + "verification_status": "calculated", + "notes": result.get("interpretation"), + "created_at": datetime.utcnow().isoformat(), + } + ) + + return result + + async def get_stats(self) -> Dict[str, Any]: + """Get media forensics statistics.""" + if not self._db: + return { + "total_analyses": 0, + "by_status": {"pending": 0, "processing": 0, "completed": 0, "failed": 0}, + "by_verification": {"verified": 0, "flagged": 0, "unknown": 0, "tampered": 0}, + "with_c2pa": 0, + "with_exif": 0, + "with_gps": 0, + "with_findings": 0, + "critical_findings_total": 0, + "high_findings_total": 0, + "ela_analyses": 0, + "sun_position_analyses": 0, + "similar_images_searches": 0, + "avg_findings_per_analysis": 0, + } + + # Initialize with frontend-expected structure + stats = { + "total_analyses": 0, + "by_status": {"pending": 0, "processing": 0, "completed": 0, "failed": 0}, + "by_verification": {"verified": 0, "flagged": 0, "unknown": 0, "tampered": 0}, + "with_c2pa": 0, + "with_exif": 0, + "with_gps": 0, + "with_findings": 0, + "critical_findings_total": 0, + "high_findings_total": 0, + "ela_analyses": 0, + "sun_position_analyses": 0, + "similar_images_searches": 0, + "avg_findings_per_analysis": 0.0, + # Also keep legacy fields for backward compatibility + "with_warnings": 0, + "ai_generated_detected": 0, + "by_integrity_status": {}, + "by_file_type": {}, + } + + try: + # Total count + total_row = await self._db.fetch_one( + "SELECT COUNT(*) as count FROM arkham_media_analyses" + ) + total = total_row["count"] if total_row else 0 + stats["total_analyses"] = total + # All records in DB are completed + stats["by_status"]["completed"] = total + + # With EXIF (camera_make not null) + exif_row = await self._db.fetch_one( + "SELECT COUNT(*) as count FROM arkham_media_analyses WHERE camera_make IS NOT NULL" + ) + stats["with_exif"] = exif_row["count"] if exif_row else 0 + + # With GPS + gps_row = await self._db.fetch_one( + "SELECT COUNT(*) as count FROM arkham_media_analyses WHERE gps_latitude IS NOT NULL" + ) + stats["with_gps"] = gps_row["count"] if gps_row else 0 + + # With C2PA + c2pa_row = await self._db.fetch_one( + "SELECT COUNT(*) as count FROM arkham_media_analyses WHERE has_c2pa = 1" + ) + stats["with_c2pa"] = c2pa_row["count"] if c2pa_row else 0 + + # With warnings/findings + warnings_row = await self._db.fetch_one( + "SELECT COUNT(*) as count FROM arkham_media_analyses WHERE warnings != '[]'" + ) + warnings_count = warnings_row["count"] if warnings_row else 0 + stats["with_warnings"] = warnings_count + stats["with_findings"] = warnings_count + + # AI generated (check warnings for AI_GENERATED) + ai_row = await self._db.fetch_one( + "SELECT COUNT(*) as count FROM arkham_media_analyses WHERE warnings LIKE '%AI_GENERATED%'" + ) + stats["ai_generated_detected"] = ai_row["count"] if ai_row else 0 + + # By integrity status (maps to by_verification) + status_rows = await self._db.fetch_all( + "SELECT integrity_status, COUNT(*) as count FROM arkham_media_analyses GROUP BY integrity_status" + ) + by_integrity = {row["integrity_status"]: row["count"] for row in status_rows} + stats["by_integrity_status"] = by_integrity + # Map to frontend verification status + stats["by_verification"]["verified"] = by_integrity.get("verified", 0) + stats["by_verification"]["flagged"] = by_integrity.get("flagged", 0) + by_integrity.get("unverified", 0) + stats["by_verification"]["unknown"] = by_integrity.get("unknown", 0) + stats["by_verification"]["tampered"] = by_integrity.get("tampered", 0) + + # By file type + type_rows = await self._db.fetch_all( + "SELECT file_type, COUNT(*) as count FROM arkham_media_analyses WHERE file_type IS NOT NULL GROUP BY file_type" + ) + stats["by_file_type"] = {row["file_type"]: row["count"] for row in type_rows} + + except Exception as e: + logger.error(f"Failed to get stats: {e}") + + return stats + + # =========================================== + # Helper Methods + # =========================================== + + def _row_to_analysis_dict(self, row: Dict[str, Any]) -> Dict[str, Any]: + """Convert a database row to an analysis dictionary.""" + # Parse JSON fields + raw_exif_data = row.get("exif_data", "{}") + if isinstance(raw_exif_data, str): + try: + raw_exif_data = json.loads(raw_exif_data) + except json.JSONDecodeError: + raw_exif_data = {} + + # Structure exif_data to match frontend ExifData type + exif_data = self._structure_exif_data(raw_exif_data, row) + + raw_c2pa_data = row.get("c2pa_data", "{}") + if isinstance(raw_c2pa_data, str): + try: + raw_c2pa_data = json.loads(raw_c2pa_data) + except json.JSONDecodeError: + raw_c2pa_data = {} + + # Transform C2PA data to frontend expected format + c2pa_data = self._transform_c2pa_for_frontend(raw_c2pa_data) + + warnings = row.get("warnings", "[]") + if isinstance(warnings, str): + try: + warnings = json.loads(warnings) + except json.JSONDecodeError: + warnings = [] + + anomalies = row.get("anomalies", "[]") + if isinstance(anomalies, str): + try: + anomalies = json.loads(anomalies) + except json.JSONDecodeError: + anomalies = [] + + # Convert string warnings to ForensicFinding objects for frontend compatibility + findings = [] + for i, warning in enumerate(warnings if isinstance(warnings, list) else []): + if isinstance(warning, str): + # Determine category and severity from warning text + category = "general" + severity = "medium" + if warning.startswith("EXIF_"): + category = "exif" + elif warning.startswith("C2PA_"): + category = "c2pa" + elif warning.startswith("AI_GENERATED"): + category = "c2pa" + severity = "high" + elif warning.startswith("TIMESTAMP_"): + category = "exif" + elif warning.startswith("GPS_"): + category = "exif" + + findings.append({ + "id": f"{row['id']}_warning_{i}", + "category": category, + "severity": severity, + "title": warning.split(":")[0] if ":" in warning else warning[:50], + "description": warning, + "evidence": {}, + "recommendation": None, + "confidence": 0.7, + "auto_detected": True, + "detected_at": row["created_at"].isoformat() if row.get("created_at") else datetime.utcnow().isoformat(), + }) + elif isinstance(warning, dict): + # Already a finding object + findings.append(warning) + + # Map integrity_status to verification_status for frontend compatibility + integrity_status = row.get("integrity_status", "unknown") + + # Derive filename from document_id or file path if stored + filename = row.get("filename") + if not filename and row.get("document_id"): + # Use document_id as filename placeholder + filename = row.get("document_id", "unknown") + + return { + "id": row["id"], + "doc_id": row.get("document_id"), # Frontend expects doc_id + "document_id": row.get("document_id"), + "filename": filename, + "file_path": row.get("file_path"), # For ELA and other operations + "tenant_id": row.get("tenant_id"), + "file_type": row.get("file_type"), + "file_size": row.get("file_size"), + "file_hash_md5": row.get("md5"), + "file_hash_sha256": row.get("sha256"), + "width": row.get("width"), + "height": row.get("height"), + "sha256": row.get("sha256"), + "md5": row.get("md5"), + "phash": row.get("phash"), + "dhash": row.get("dhash"), + "ahash": row.get("ahash"), + "exif_data": exif_data, + "camera_make": row.get("camera_make"), + "camera_model": row.get("camera_model"), + "software": row.get("software"), + "datetime_original": row.get("datetime_original"), + "datetime_digitized": row.get("datetime_digitized"), + "datetime_modified": row.get("datetime_modified"), + "gps_latitude": row.get("gps_latitude"), + "gps_longitude": row.get("gps_longitude"), + "gps_altitude": row.get("gps_altitude"), + "c2pa_data": c2pa_data, + "has_c2pa": bool(row.get("has_c2pa")), + "c2pa_signer": row.get("c2pa_signer"), + "c2pa_timestamp": row.get("c2pa_timestamp"), + "warnings": warnings, + "anomalies": anomalies, + # Status fields for frontend compatibility + "status": "completed", # Analysis is always completed when in DB + "verification_status": integrity_status, + "integrity_status": integrity_status, + "confidence_score": row.get("confidence_score", 0.0), + # Findings fields - properly structured ForensicFinding objects + "findings": findings, + "findings_count": len(findings), + "critical_findings": sum(1 for f in findings if f.get("severity") == "critical"), + "high_findings": sum(1 for f in findings if f.get("severity") == "high"), + # Timestamps + "analyzed_at": row["created_at"].isoformat() if row.get("created_at") else None, + "created_at": row["created_at"].isoformat() if row.get("created_at") else None, + "updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None, + "analyzed_by": "system", + "notes": None, + # Component results (null by default, populated when requested) + "ela_result": None, + "sun_position_result": None, + "similar_images_result": None, + } + + def _structure_exif_data(self, raw_exif: Dict[str, Any], row: Dict[str, Any]) -> Dict[str, Any]: + """ + Transform raw EXIF data into the structured format expected by the frontend. + + Frontend expects: + { + camera: { make, model, serial_number, lens_make, lens_model, lens_serial }, + image: { width, height, orientation, color_space, bits_per_sample, compression }, + gps: { latitude, longitude, altitude, altitude_ref, gps_timestamp, gps_date, direction, direction_ref }, + capture: { exposure_time, f_number, iso, focal_length, focal_length_35mm, exposure_mode, exposure_program, metering_mode, white_balance, flash }, + timestamps: { datetime_original, datetime_digitized, datetime_modified, timezone_offset }, + software: { software, processing_software, host_computer, firmware }, + raw_data: { ... original raw exif ... }, + warnings: [ ... ] + } + """ + # Build camera info from raw EXIF or row columns + camera = { + "make": row.get("camera_make") or raw_exif.get("Make") or raw_exif.get("Image Make"), + "model": row.get("camera_model") or raw_exif.get("Model") or raw_exif.get("Image Model"), + "serial_number": raw_exif.get("SerialNumber") or raw_exif.get("EXIF SerialNumber"), + "lens_make": raw_exif.get("LensMake") or raw_exif.get("EXIF LensMake"), + "lens_model": raw_exif.get("LensModel") or raw_exif.get("EXIF LensModel"), + "lens_serial": raw_exif.get("LensSerialNumber") or raw_exif.get("EXIF LensSerialNumber"), + } + + # Build image info + image = { + "width": row.get("width") or raw_exif.get("EXIF ExifImageWidth") or raw_exif.get("ImageWidth"), + "height": row.get("height") or raw_exif.get("EXIF ExifImageLength") or raw_exif.get("ImageLength"), + "orientation": raw_exif.get("Orientation") or raw_exif.get("Image Orientation"), + "color_space": raw_exif.get("EXIF ColorSpace") or raw_exif.get("ColorSpace"), + "bits_per_sample": raw_exif.get("BitsPerSample") or raw_exif.get("Image BitsPerSample"), + "compression": raw_exif.get("Compression") or raw_exif.get("Image Compression"), + } + + # Build GPS info + gps = { + "latitude": row.get("gps_latitude"), + "longitude": row.get("gps_longitude"), + "altitude": row.get("gps_altitude"), + "altitude_ref": raw_exif.get("GPS GPSAltitudeRef"), + "gps_timestamp": raw_exif.get("GPS GPSTimeStamp"), + "gps_date": raw_exif.get("GPS GPSDate"), + "direction": None, # Would need parsing from GPS GPSImgDirection + "direction_ref": raw_exif.get("GPS GPSImgDirectionRef"), + } + # Try to parse direction + if raw_exif.get("GPS GPSImgDirection"): + try: + direction_str = str(raw_exif.get("GPS GPSImgDirection")) + if "/" in direction_str: + num, den = direction_str.split("/") + gps["direction"] = float(num) / float(den) + else: + gps["direction"] = float(direction_str) + except (ValueError, ZeroDivisionError): + pass + + # Build capture settings + capture = { + "exposure_time": str(raw_exif.get("EXIF ExposureTime")) if raw_exif.get("EXIF ExposureTime") else None, + "f_number": None, + "iso": None, + "focal_length": None, + "focal_length_35mm": None, + "exposure_mode": raw_exif.get("EXIF ExposureMode"), + "exposure_program": raw_exif.get("EXIF ExposureProgram"), + "metering_mode": raw_exif.get("EXIF MeteringMode"), + "white_balance": raw_exif.get("EXIF WhiteBalance"), + "flash": raw_exif.get("EXIF Flash"), + } + # Parse numeric values + if raw_exif.get("EXIF FNumber"): + try: + fn_str = str(raw_exif.get("EXIF FNumber")) + if "/" in fn_str: + num, den = fn_str.split("/") + capture["f_number"] = round(float(num) / float(den), 1) + else: + capture["f_number"] = float(fn_str) + except (ValueError, ZeroDivisionError): + pass + if raw_exif.get("EXIF ISOSpeedRatings"): + try: + capture["iso"] = int(str(raw_exif.get("EXIF ISOSpeedRatings")).split()[0]) + except (ValueError, IndexError): + pass + if raw_exif.get("EXIF FocalLength"): + try: + fl_str = str(raw_exif.get("EXIF FocalLength")) + if "/" in fl_str: + num, den = fl_str.split("/") + capture["focal_length"] = round(float(num) / float(den), 1) + else: + capture["focal_length"] = float(fl_str) + except (ValueError, ZeroDivisionError): + pass + if raw_exif.get("EXIF FocalLengthIn35mmFilm"): + try: + capture["focal_length_35mm"] = int(str(raw_exif.get("EXIF FocalLengthIn35mmFilm"))) + except ValueError: + pass + + # Build timestamps + timestamps = { + "datetime_original": row.get("datetime_original") or raw_exif.get("EXIF DateTimeOriginal"), + "datetime_digitized": row.get("datetime_digitized") or raw_exif.get("EXIF DateTimeDigitized"), + "datetime_modified": row.get("datetime_modified") or raw_exif.get("DateTime") or raw_exif.get("Image DateTime"), + "timezone_offset": raw_exif.get("EXIF OffsetTime") or raw_exif.get("OffsetTime"), + } + + # Build software info + software = { + "software": row.get("software") or raw_exif.get("Software") or raw_exif.get("Image Software"), + "processing_software": raw_exif.get("ProcessingSoftware"), + "host_computer": raw_exif.get("HostComputer") or raw_exif.get("Image HostComputer"), + "firmware": raw_exif.get("Firmware") or raw_exif.get("MakerNote FirmwareVersion"), + } + + # Collect warnings from raw exif (if any embedded warnings) + warnings = [] + + return { + "camera": camera, + "image": image, + "gps": gps, + "capture": capture, + "timestamps": timestamps, + "software": software, + "raw_data": raw_exif, + "warnings": warnings, + } + + def _transform_c2pa_for_frontend(self, raw_c2pa: Dict[str, Any]) -> Dict[str, Any]: + """ + Transform backend C2PA data into the format expected by the frontend. + + Backend stores: + { + has_c2pa: bool, + manifests: ["urn:..."], # Array of URN strings + active_manifest: "urn:...", + signature_valid: bool, + signer: "Adobe Inc.", + timestamp: "...", + actions: [...], + ingredients: [...], + claim_generator: "...", + raw_manifest: {...} # Full raw manifest data + } + + Frontend expects: + { + has_manifest: bool, + manifests: C2PAManifest[], # Array of full manifest objects + active_manifest_index: number | null, + validation_status: 'valid' | 'invalid' | 'expired' | 'revoked' | 'unknown', + validation_errors: string[], + provenance_chain: string[], + } + """ + if not raw_c2pa or not raw_c2pa.get("has_c2pa"): + return { + "has_manifest": False, + "manifests": [], + "active_manifest_index": None, + "validation_status": "unknown", + "validation_errors": [], + "provenance_chain": [], + } + + # Build the manifest object from raw data + manifests = [] + + # Get raw manifest data if available + raw_manifest_store = raw_c2pa.get("raw_manifest", {}) + active_manifest_id = raw_c2pa.get("active_manifest") + + # Determine validation status + if raw_c2pa.get("signature_valid"): + validation_status = "valid" + elif raw_c2pa.get("signature_verification_available") is False: + # Signature verification not available (no trust anchors) + validation_status = "unknown" + else: + validation_status = "invalid" + + # Build manifest from available data + manifest = { + "claim_generator": raw_c2pa.get("claim_generator"), + "title": None, + "format": None, + "instance_id": active_manifest_id, + "assertions": [], + "actions": [], + "ingredients": [], + "signer": None, + "signature_date": raw_c2pa.get("timestamp"), + } + + # Build signer object + if raw_c2pa.get("signer"): + manifest["signer"] = { + "name": raw_c2pa.get("signer"), + "organization": None, + "issued_date": None, + "expiry_date": None, + "is_trusted": raw_c2pa.get("signature_valid", False), + "trust_chain": [], + "validation_status": validation_status, + } + # Try to extract organization from signer name + signer_name = raw_c2pa.get("signer", "") + if " Inc" in signer_name or " LLC" in signer_name or " Corp" in signer_name: + manifest["signer"]["organization"] = signer_name + + # Transform actions + for action in raw_c2pa.get("actions", []): + if isinstance(action, dict): + manifest["actions"].append({ + "action": action.get("action", "unknown"), + "when": action.get("when"), + "software_agent": action.get("softwareAgent"), + "parameters": action.get("parameters", {}), + }) + elif isinstance(action, str): + manifest["actions"].append({ + "action": action, + "when": None, + "software_agent": None, + "parameters": {}, + }) + + # Transform ingredients + for ingredient in raw_c2pa.get("ingredients", []): + if isinstance(ingredient, dict): + manifest["ingredients"].append({ + "title": ingredient.get("title"), + "format": ingredient.get("format"), + "document_id": ingredient.get("document_id"), + "instance_id": ingredient.get("instance_id"), + "relationship": ingredient.get("relationship", "componentOf"), + "thumbnail": ingredient.get("thumbnail"), + }) + + # Try to get more details from raw_manifest if available + if raw_manifest_store and "manifests" in raw_manifest_store and active_manifest_id: + if active_manifest_id in raw_manifest_store["manifests"]: + full_manifest = raw_manifest_store["manifests"][active_manifest_id] + + # Get title from manifest + if "title" in full_manifest: + manifest["title"] = full_manifest["title"] + if "format" in full_manifest: + manifest["format"] = full_manifest["format"] + + # Get assertions + for assertion in full_manifest.get("assertions", []): + manifest["assertions"].append({ + "label": assertion.get("label", ""), + "data": assertion.get("data", {}), + "instance": assertion.get("instance"), + }) + + # Get signature info + if "signature_info" in full_manifest: + sig_info = full_manifest["signature_info"] + if manifest["signer"]: + manifest["signer"]["issued_date"] = sig_info.get("time") + manifest["signature_date"] = sig_info.get("time") + + manifests.append(manifest) + + # Build provenance chain from ingredient titles + provenance_chain = [] + for ing in manifest["ingredients"]: + if ing.get("title"): + provenance_chain.append(ing["title"]) + + return { + "has_manifest": True, + "manifests": manifests, + "active_manifest_index": 0, # Always first manifest since we only build one + "validation_status": validation_status, + "validation_errors": [], + "provenance_chain": provenance_chain, + } diff --git a/packages/arkham-shard-media-forensics/pyproject.toml b/packages/arkham-shard-media-forensics/pyproject.toml new file mode 100644 index 0000000..02f36cd --- /dev/null +++ b/packages/arkham-shard-media-forensics/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "arkham-shard-media-forensics" +version = "0.1.0" +description = "Media metadata extraction and forensic analysis for images" +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.10" + +dependencies = [ + "arkham-frame>=0.1.0", + "pydantic>=2.0.0", + "pillow>=10.0.0", + "exifread>=3.0.0", + "pysolar>=0.10", + "scipy>=1.10.0", + "numpy>=1.20.0", + "structlog>=21.0.0", + "c2pa-python>=0.4.0", +] + +[project.optional-dependencies] +dev = ["pytest", "pytest-asyncio", "black", "mypy"] + +[project.entry-points."arkham.shards"] +media-forensics = "arkham_shard_media_forensics:MediaForensicsShard" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/arkham-shard-media-forensics/shard.yaml b/packages/arkham-shard-media-forensics/shard.yaml new file mode 100644 index 0000000..f9dd1d3 --- /dev/null +++ b/packages/arkham-shard-media-forensics/shard.yaml @@ -0,0 +1,56 @@ +# Media Forensics Shard - Image authenticity and metadata analysis +# Production Manifest v1.0 + +name: media-forensics +version: "0.1.0" +description: Media metadata extraction and forensic analysis for images +entry_point: arkham_shard_media_forensics:MediaForensicsShard +api_prefix: /api/media-forensics +requires_frame: ">=0.1.0" + +navigation: + category: Analysis + order: 46 + icon: ImageSearch + label: Media Forensics + route: /media-forensics + badge_endpoint: /api/media-forensics/analyses/count + badge_type: count + +dependencies: + services: + - database + - events + - storage + optional: + - llm + shards: [] + +capabilities: + - exif_extraction + - perceptual_hashing + - c2pa_verification + - ela_analysis + - sun_position_verification + - similar_image_detection + +events: + publishes: + - media.metadata.extracted + - media.hash.computed + - media.c2pa.verified + - media.ela.generated + - media.similar.found + subscribes: + - document.ingested + - document.processed + +state: + strategy: url + url_params: + - documentId + - analysisId + - tab + +ui: + has_custom_ui: true diff --git a/packages/arkham-shard-shell/src/App.tsx b/packages/arkham-shard-shell/src/App.tsx index 464049d..521e509 100644 --- a/packages/arkham-shard-shell/src/App.tsx +++ b/packages/arkham-shard-shell/src/App.tsx @@ -77,6 +77,9 @@ import { CredibilityPage } from './pages/credibility'; import { PatternsPage } from './pages/patterns'; import { ProvenancePage } from './pages/provenance'; +// Wave 6 shard imports +import { MediaForensicsPage } from './pages/media-forensics'; + // Wave 4 shard imports import { ExportPage } from './pages/export'; import { ReportsPage } from './pages/reports'; @@ -210,6 +213,10 @@ export function App() { {/* Summary shard */} } /> + {/* Wave 6 shards */} + {/* Media Forensics shard */} + } /> + {/* Catch-all: try to render shard with generic UI */} } /> diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/MediaForensicsPage.css b/packages/arkham-shard-shell/src/pages/media-forensics/MediaForensicsPage.css new file mode 100644 index 0000000..859a724 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/MediaForensicsPage.css @@ -0,0 +1,2243 @@ +/* Media Forensics Page Styles */ + +.media-forensics-page { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-primary); +} + +/* Stats Grid */ +.forensics-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + padding: 0 1rem 1rem; +} + +.forensics-stat-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem; + background: var(--color-bg-secondary, #1e2530); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; + text-align: center; +} + +.forensics-stat-card svg { + color: var(--color-primary, #3b82f6); +} + +.forensics-stat-card.stat-card-warning svg { + color: var(--arkham-error, #ef4444); +} + +.forensics-stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--color-text-primary, #e5e7eb); +} + +.forensics-stat-label { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Filters */ +.forensics-filters { + display: flex; + align-items: flex-end; + gap: 1rem; + padding: 0 1rem 1rem; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.filter-group label { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.filter-group select { + padding: 0.5rem 0.75rem; + background: var(--color-bg-secondary, #1e2530); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 6px; + color: var(--color-text-primary, #e5e7eb); + font-size: 0.875rem; + min-width: 150px; +} + +.filter-group select:focus { + outline: none; + border-color: var(--color-primary, #3b82f6); +} + +/* Layout */ +.forensics-layout { + display: flex; + flex: 1; + gap: 1rem; + padding: 0 1rem 1rem; + overflow: hidden; +} + +/* List Container */ +.forensics-list-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-bg-secondary, #1e2530); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; +} + +.forensics-list { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Analysis Card */ +.analysis-card { + position: relative; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; + padding: 1rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.analysis-card:hover { + background: var(--color-bg-hover, #252d38); + border-color: var(--color-primary, #3b82f6); +} + +.analysis-card.selected { + background: #1a2a3d; + border: 2px solid #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); + padding: calc(1rem - 1px); +} + +.selection-indicator { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%); + border-radius: 8px 0 0 8px; +} + +.analysis-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + gap: 0.75rem; +} + +.analysis-file-info { + display: flex; + align-items: flex-start; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.analysis-file-info svg { + color: var(--color-text-tertiary, #6b7280); + flex-shrink: 0; + margin-top: 2px; +} + +.file-details { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.filename { + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-meta { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.analysis-badges { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.badge-blue { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; +} + +.badge-yellow { + background: rgba(234, 179, 8, 0.2); + color: #fbbf24; +} + +.badge-green { + background: rgba(34, 197, 94, 0.2); + color: #4ade80; +} + +.badge-red { + background: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +.verification-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + color: white; +} + +/* Analysis Indicators */ +.analysis-indicators { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.indicator { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 4px; + font-size: 0.7rem; + color: var(--color-text-secondary, #9ca3af); +} + +.indicator svg { + color: var(--color-text-tertiary, #6b7280); +} + +.indicator-c2pa { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; +} + +.indicator-c2pa svg { + color: #4ade80; +} + +/* Findings Summary */ +.analysis-findings-summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: 6px; + margin-bottom: 0.75rem; + font-size: 0.8rem; + color: #fbbf24; +} + +.analysis-findings-summary svg { + color: #fbbf24; +} + +.critical-count { + color: #f87171; + font-weight: 600; + margin-left: 0.25rem; +} + +/* Analysis Meta */ +.analysis-meta { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.meta-item { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.meta-item svg { + color: var(--color-text-tertiary, #6b7280); +} + +/* Pagination */ +.forensics-pagination { + display: flex; + justify-content: center; + gap: 0.5rem; + padding: 1rem; + border-top: 1px solid var(--color-border, #3f4a5a); +} + +/* Detail Panel */ +.forensics-detail-panel { + width: 480px; + background: var(--color-bg-secondary, #1e2530); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid var(--color-border, #3f4a5a); +} + +.detail-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + margin-right: 0.5rem; +} + +.detail-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.close-btn { + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: var(--color-text-secondary, #9ca3af); + transition: color 0.15s ease; +} + +.close-btn:hover { + color: var(--color-text-primary, #e5e7eb); +} + +/* Verification Banner */ +.verification-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; + color: white; + font-weight: 600; + font-size: 0.875rem; +} + +/* Detail Tabs */ +.detail-tabs { + display: flex; + flex-wrap: wrap; + border-bottom: 1px solid var(--color-border, #3f4a5a); + gap: 0; +} + +.detail-tab { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.625rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + color: var(--color-text-secondary, #9ca3af); + font-size: 0.7rem; + font-weight: 500; + transition: all 0.15s ease; + white-space: nowrap; +} + +.detail-tab:hover:not(:disabled) { + color: var(--color-text-primary, #e5e7eb); + background: var(--color-bg-hover, #252d38); +} + +.detail-tab.active { + color: var(--color-primary, #3b82f6); + border-bottom-color: var(--color-primary, #3b82f6); +} + +.detail-tab:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + background: var(--arkham-success, #22c55e); + border-radius: 50%; + color: white; +} + +.tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + background: var(--arkham-warning, #eab308); + border-radius: 9px; + font-size: 0.65rem; + font-weight: 700; + color: #000; +} + +/* Detail Content */ +.detail-content { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +/* Content States */ +.content-loading, +.content-error, +.content-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 1rem; + color: var(--color-text-secondary, #9ca3af); + padding: 2rem; + text-align: center; +} + +.content-loading svg, +.content-error svg, +.content-empty svg { + color: var(--color-text-tertiary, #6b7280); +} + +.content-empty h2, +.content-error h2 { + margin: 0; + font-size: 1.25rem; + color: var(--color-text-primary, #e5e7eb); +} + +.content-empty p, +.content-error p { + margin: 0; + font-size: 0.875rem; + max-width: 300px; +} + +/* Panel Sections */ +.panel-section { + margin-bottom: 1.5rem; +} + +.panel-section:last-child { + margin-bottom: 0; +} + +.panel-section-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.panel-section-header h4 { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-secondary, #9ca3af); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.panel-section-header svg { + color: var(--color-text-tertiary, #6b7280); +} + +/* Metadata Grid */ +.metadata-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + +.metadata-item { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem; + background: var(--color-bg-primary, #151a21); + border-radius: 4px; +} + +.metadata-item.full-width { + grid-column: span 2; +} + +.metadata-label { + font-size: 0.7rem; + color: var(--color-text-tertiary, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.metadata-value { + font-size: 0.875rem; + color: var(--color-text-primary, #e5e7eb); + word-break: break-word; +} + +.metadata-value.monospace { + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.8rem; +} + +/* Warning/Finding Cards */ +.finding-card { + padding: 0.75rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-left-width: 3px; + border-radius: 6px; + margin-bottom: 0.75rem; +} + +.finding-card:last-child { + margin-bottom: 0; +} + +.finding-card.severity-critical { + border-left-color: var(--arkham-error, #ef4444); +} + +.finding-card.severity-high { + border-left-color: var(--arkham-warning, #f97316); +} + +.finding-card.severity-medium { + border-left-color: var(--arkham-warning, #eab308); +} + +.finding-card.severity-low { + border-left-color: var(--arkham-info, #3b82f6); +} + +.finding-card.severity-info { + border-left-color: var(--color-text-tertiary, #6b7280); +} + +.finding-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; +} + +.finding-title { + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); + font-size: 0.875rem; +} + +.finding-severity { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.125rem 0.375rem; + border-radius: 3px; +} + +.finding-severity.severity-critical { + background: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +.finding-severity.severity-high { + background: rgba(249, 115, 22, 0.2); + color: #fb923c; +} + +.finding-severity.severity-medium { + background: rgba(234, 179, 8, 0.2); + color: #fbbf24; +} + +.finding-severity.severity-low { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; +} + +.finding-severity.severity-info { + background: rgba(107, 114, 128, 0.2); + color: #9ca3af; +} + +.finding-description { + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); + line-height: 1.5; + margin: 0; +} + +.finding-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.5rem; + font-size: 0.7rem; + color: var(--color-text-tertiary, #6b7280); +} + +/* ELA Viewer Specific */ +.ela-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.ela-controls { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem; + background: var(--color-bg-primary, #151a21); + border-radius: 6px; +} + +.ela-controls label { + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); +} + +.ela-controls input[type="range"] { + flex: 1; +} + +.ela-images { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.ela-image-container { + position: relative; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 6px; + overflow: hidden; +} + +.ela-image-label { + position: absolute; + top: 0.5rem; + left: 0.5rem; + padding: 0.25rem 0.5rem; + background: rgba(0, 0, 0, 0.7); + border-radius: 4px; + font-size: 0.7rem; + color: white; + z-index: 1; +} + +.ela-image-container img { + width: 100%; + height: auto; + display: block; +} + +.ela-regions-list { + margin-top: 1rem; +} + +.ela-region-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + background: var(--color-bg-primary, #151a21); + border-radius: 4px; + margin-bottom: 0.5rem; + font-size: 0.8rem; +} + +.ela-region-item.suspicious { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +/* C2PA Specific */ +.c2pa-signer-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; + margin-bottom: 1rem; +} + +.c2pa-signer-badge { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--arkham-success, #22c55e); + color: white; +} + +.c2pa-signer-badge.invalid { + background: var(--arkham-error, #ef4444); +} + +.c2pa-signer-info { + flex: 1; +} + +.c2pa-signer-name { + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); + margin-bottom: 0.25rem; +} + +.c2pa-signer-org { + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); +} + +.c2pa-validation-status { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + color: white; +} + +.c2pa-actions-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.c2pa-action-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--color-bg-primary, #151a21); + border-radius: 4px; + font-size: 0.8rem; +} + +.c2pa-action-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 4px; + color: var(--color-text-secondary, #9ca3af); +} + +.c2pa-action-details { + flex: 1; +} + +.c2pa-action-name { + font-weight: 500; + color: var(--color-text-primary, #e5e7eb); +} + +.c2pa-action-meta { + font-size: 0.7rem; + color: var(--color-text-tertiary, #6b7280); +} + +/* Similar Images Grid */ +.similar-images-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.similar-image-card { + position: relative; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 6px; + overflow: hidden; + cursor: pointer; + transition: all 0.15s ease; +} + +.similar-image-card:hover { + border-color: var(--color-primary, #3b82f6); + transform: translateY(-2px); +} + +.similar-image-thumb { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + background: var(--color-bg-tertiary, #2a323e); +} + +.similar-image-info { + padding: 0.5rem; +} + +.similar-image-score { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); +} + +.similar-image-type { + font-size: 0.7rem; + color: var(--color-text-tertiary, #6b7280); + text-transform: capitalize; +} + +/* Sun Position Panel */ +.sun-position-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sun-consistency-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + font-weight: 500; +} + +.sun-consistency-banner.consistent { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + color: #4ade80; +} + +.sun-consistency-banner.inconsistent { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #f87171; +} + +.sun-data-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.sun-data-card { + padding: 1rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 6px; +} + +.sun-data-card h5 { + margin: 0 0 0.75rem 0; + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.sun-data-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-primary, #e5e7eb); +} + +.sun-data-unit { + font-size: 0.875rem; + color: var(--color-text-tertiary, #6b7280); + margin-left: 0.25rem; +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-content { + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + background: #1e2530; + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border, #3f4a5a); +} + +.modal-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #f3f4f6; +} + +.modal-body { + padding: 1.25rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.85rem; + font-weight: 500; + color: #d1d5db; +} + +.form-group select, +.form-group input { + width: 100%; + padding: 0.625rem 0.75rem; + background: #151a21; + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 6px; + font-size: 0.9rem; + color: #e5e7eb; +} + +.form-group select:focus, +.form-group input:focus { + outline: none; + border-color: var(--color-primary, #3b82f6); +} + +.form-hint { + display: block; + margin-top: 0.25rem; + font-size: 0.75rem; + color: #6b7280; +} + +.checkbox-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: #d1d5db; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-top: 1px solid var(--color-border, #3f4a5a); + background: #1a1f28; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-primary, #3b82f6); + border-color: var(--color-primary, #3b82f6); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover, #2563eb); + border-color: var(--color-primary-hover, #2563eb); +} + +.btn-secondary { + background: var(--color-bg-secondary, #1e2530); + color: var(--color-text-primary, #e5e7eb); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-bg-hover, #252d38); + border-color: var(--color-primary, #3b82f6); +} + +.btn-danger { + background: var(--arkham-error, #dc2626); + border-color: var(--arkham-error, #dc2626); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #b91c1c; + border-color: #b91c1c; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; +} + +/* Animations */ +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ========================================== + Additional Component Styles + ========================================== */ + +/* EXIF Panel */ +.exif-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.exif-warnings { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.exif-warning-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: 4px; + font-size: 0.8rem; + color: #fbbf24; +} + +.exif-warning-item svg { + color: #fbbf24; + flex-shrink: 0; +} + +/* C2PA Panel */ +.c2pa-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.c2pa-status-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; + border-radius: 8px; + color: white; + font-weight: 600; +} + +.c2pa-errors { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.c2pa-error-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 4px; + font-size: 0.8rem; + color: #f87171; +} + +.c2pa-error-item svg { + color: #f87171; + flex-shrink: 0; +} + +.c2pa-signer-date { + font-size: 0.75rem; + color: var(--color-text-tertiary, #6b7280); + margin-top: 0.25rem; +} + +.c2pa-trust-chain { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.75rem; + padding: 0.5rem; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 4px; + font-size: 0.75rem; +} + +.trust-chain-label { + color: var(--color-text-secondary, #9ca3af); + margin-right: 0.5rem; +} + +.trust-chain-item { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--color-text-primary, #e5e7eb); +} + +.c2pa-ingredients-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.c2pa-ingredient-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: var(--color-bg-primary, #151a21); + border-radius: 4px; +} + +.ingredient-thumb { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 4px; + background: var(--color-bg-tertiary, #2a323e); +} + +.ingredient-details { + flex: 1; +} + +.ingredient-title { + font-weight: 500; + color: var(--color-text-primary, #e5e7eb); +} + +.ingredient-meta { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.ingredient-relationship { + margin-left: 0.5rem; + padding: 0.125rem 0.375rem; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 3px; + text-transform: capitalize; +} + +.c2pa-assertions-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.c2pa-assertion-item { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.625rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 4px; + font-size: 0.75rem; +} + +.assertion-label { + color: var(--color-text-primary, #e5e7eb); +} + +.assertion-instance { + color: var(--color-text-tertiary, #6b7280); +} + +.c2pa-provenance-chain { + display: flex; + flex-direction: column; +} + +.provenance-step { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.provenance-marker { + display: flex; + flex-direction: column; + align-items: center; + width: 16px; +} + +.provenance-dot { + width: 8px; + height: 8px; + background: var(--color-primary, #3b82f6); + border-radius: 50%; +} + +.provenance-line { + width: 2px; + flex: 1; + min-height: 24px; + background: var(--color-border, #3f4a5a); +} + +.provenance-content { + flex: 1; + padding-bottom: 1rem; + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); +} + +.c2pa-multiple-notice { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 4px; + font-size: 0.8rem; + color: #60a5fa; +} + +/* ELA Panel */ +.ela-result-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; +} + +.ela-result-banner.clean { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + color: #4ade80; +} + +.ela-result-banner.warning { + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.3); + color: #fbbf24; +} + +.ela-result-text { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.ela-result-title { + font-weight: 600; +} + +.ela-result-confidence { + font-size: 0.75rem; + opacity: 0.8; +} + +.ela-view-toggle { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.view-toggle-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 4px; + color: var(--color-text-secondary, #9ca3af); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.view-toggle-btn:hover:not(:disabled) { + border-color: var(--color-primary, #3b82f6); + color: var(--color-text-primary, #e5e7eb); +} + +.view-toggle-btn.active { + background: var(--color-primary, #3b82f6); + border-color: var(--color-primary, #3b82f6); + color: white; +} + +.view-toggle-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ela-images.ela-only { + grid-template-columns: 1fr; +} + +.ela-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + color: var(--color-text-tertiary, #6b7280); + font-size: 0.8rem; +} + +.quality-value { + min-width: 40px; + text-align: right; + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); +} + +.region-info { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.region-coords { + font-weight: 500; + color: var(--color-text-primary, #e5e7eb); +} + +.region-description { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.region-metrics { + display: flex; + gap: 0.75rem; + font-size: 0.75rem; + color: var(--color-text-tertiary, #6b7280); +} + +.ela-explanation { + display: flex; + gap: 0.5rem; + padding: 0.75rem; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 6px; + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.ela-explanation svg { + flex-shrink: 0; + color: var(--color-text-tertiary, #6b7280); +} + +.ela-explanation p { + margin: 0; + line-height: 1.5; +} + +/* Sun Position Panel */ +.sun-override-form { + padding: 1rem; + background: var(--color-bg-secondary, #1e2530); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; +} + +.override-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.override-input-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.override-input-group.full-width { + grid-column: span 2; +} + +.override-input-group label { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.override-input-group input { + padding: 0.5rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 4px; + color: var(--color-text-primary, #e5e7eb); + font-size: 0.875rem; +} + +.override-input-group input:focus { + outline: none; + border-color: var(--color-primary, #3b82f6); +} + +.sun-inconsistencies { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.inconsistency-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 4px; + font-size: 0.8rem; + color: #f87171; +} + +.inconsistency-item svg { + color: #f87171; + flex-shrink: 0; +} + +.consistency-title { + font-weight: 600; +} + +.consistency-confidence { + font-size: 0.8rem; + opacity: 0.8; +} + +.sun-location-data { + display: flex; + gap: 1rem; +} + +.location-coord { + display: flex; + gap: 0.25rem; +} + +.coord-label { + color: var(--color-text-tertiary, #6b7280); +} + +.coord-value { + font-family: monospace; + color: var(--color-text-primary, #e5e7eb); +} + +.claimed-time { + display: flex; + align-items: center; + gap: 0.375rem; + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); +} + +.sun-position-data { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sun-metric { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.metric-label { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.shadow-consistency { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.consistency-label { + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); +} + +.consistency-bar { + flex: 1; + height: 8px; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 4px; + overflow: hidden; +} + +.consistency-fill { + height: 100%; + transition: width 0.3s ease; +} + +.consistency-value { + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); +} + +.detected-shadows h5 { + margin: 1rem 0 0.5rem; + font-size: 0.8rem; + color: var(--color-text-secondary, #9ca3af); +} + +.shadows-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.shadow-item { + display: flex; + justify-content: space-between; + padding: 0.5rem; + background: var(--color-bg-primary, #151a21); + border-radius: 4px; + font-size: 0.8rem; +} + +.shadow-id { + color: var(--color-text-primary, #e5e7eb); +} + +.shadow-direction, +.shadow-confidence { + color: var(--color-text-secondary, #9ca3af); +} + +/* Similar Images Panel */ +.similar-images-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.similar-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; +} + +.similar-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem; + background: var(--color-bg-primary, #151a21); + border-radius: 6px; +} + +.similar-stat .stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-primary, #e5e7eb); +} + +.similar-stat .stat-label { + font-size: 0.65rem; + color: var(--color-text-tertiary, #6b7280); + text-transform: uppercase; +} + +.similarity-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 700; + color: white; +} + +.similar-image-thumb.placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-tertiary, #6b7280); +} + +.selected-image-details { + padding: 1rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-primary, #3b82f6); + border-radius: 8px; +} + +.search-options { + padding: 1rem; + background: var(--color-bg-secondary, #1e2530); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; +} + +.search-type-selector { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.search-type-selector label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--color-text-secondary, #9ca3af); + cursor: pointer; +} + +.search-type-selector label:hover { + color: var(--color-text-primary, #e5e7eb); +} + +.search-type-selector.inline { + flex-direction: row; +} + +.search-type-selector.inline select { + padding: 0.375rem 0.5rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 4px; + color: var(--color-text-primary, #e5e7eb); + font-size: 0.8rem; +} + +.search-controls { + display: flex; + align-items: center; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border, #3f4a5a); +} + +/* Warnings Panel */ +.warnings-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.warnings-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: 8px; +} + +.summary-total { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: #fbbf24; +} + +.summary-breakdown { + display: flex; + gap: 1rem; + font-size: 0.8rem; +} + +.severity-count { + font-weight: 600; +} + +.findings-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.finding-title-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.finding-recommendation { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 0.5rem; + padding: 0.5rem; + background: rgba(59, 130, 246, 0.1); + border-radius: 4px; + font-size: 0.75rem; + color: #60a5fa; +} + +.finding-recommendation svg { + flex-shrink: 0; + margin-top: 2px; +} + +.finding-evidence { + margin-top: 0.5rem; +} + +.finding-evidence details { + font-size: 0.75rem; +} + +.finding-evidence summary { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 4px; + color: var(--color-text-secondary, #9ca3af); + cursor: pointer; +} + +.finding-evidence summary:hover { + color: var(--color-text-primary, #e5e7eb); +} + +.finding-evidence pre { + margin: 0.5rem 0 0; + padding: 0.5rem; + background: var(--color-bg-primary, #151a21); + border-radius: 4px; + overflow-x: auto; + font-size: 0.7rem; + color: var(--color-text-secondary, #9ca3af); +} + +.severity-legend { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border, #3f4a5a); +} + +.severity-legend h5 { + margin: 0 0 0.5rem; + font-size: 0.75rem; + color: var(--color-text-tertiary, #6b7280); + text-transform: uppercase; +} + +.legend-items { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.legend-color { + width: 12px; + height: 12px; + border-radius: 2px; +} + +.legend-label { + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +/* Page Header */ +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--color-border, #3f4a5a); +} + +.page-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.page-title svg { + color: var(--color-primary, #3b82f6); +} + +.page-title h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text-primary, #e5e7eb); +} + +.page-description { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-secondary, #9ca3af); +} + +.page-actions { + display: flex; + gap: 0.5rem; +} + +/* Empty state for panels */ +.panel-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + text-align: center; + color: var(--color-text-secondary, #9ca3af); +} + +.panel-empty svg { + color: var(--color-text-tertiary, #6b7280); +} + +.panel-empty p { + margin: 0; + font-size: 0.875rem; +} + +/* GPS Map placeholder */ +.gps-map-placeholder { + width: 100%; + height: 200px; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-tertiary, #6b7280); + font-size: 0.875rem; +} + +/* Mode Toggle */ +.mode-toggle { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.25rem; + background: var(--color-bg-primary, #151a21); + border-radius: 8px; +} + +.mode-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + background: transparent; + border: none; + border-radius: 6px; + color: var(--color-text-secondary, #9ca3af); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.mode-btn:hover:not(:disabled) { + color: var(--color-text-primary, #e5e7eb); + background: var(--color-bg-hover, #252d38); +} + +.mode-btn.active { + background: var(--color-primary, #3b82f6); + color: white; +} + +.mode-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Upload Dropzone */ +.upload-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 2rem; + border: 2px dashed var(--color-border, #3f4a5a); + border-radius: 8px; + background: var(--color-bg-primary, #151a21); + transition: all 0.15s ease; + min-height: 160px; +} + +.upload-dropzone:hover { + border-color: var(--color-primary, #3b82f6); + background: rgba(59, 130, 246, 0.05); +} + +.upload-dropzone.drag-active { + border-color: var(--color-primary, #3b82f6); + background: rgba(59, 130, 246, 0.1); + border-style: solid; +} + +.upload-dropzone.has-file { + border-color: var(--arkham-success, #22c55e); + border-style: solid; + background: rgba(34, 197, 94, 0.05); +} + +.dropzone-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + text-align: center; +} + +.dropzone-content svg { + color: var(--color-text-tertiary, #6b7280); +} + +.dropzone-content p { + margin: 0; + color: var(--color-text-secondary, #9ca3af); + font-size: 0.9rem; +} + +.supported-formats { + font-size: 0.75rem; + color: var(--color-text-tertiary, #6b7280); +} + +.selected-file { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem; + background: var(--color-bg-secondary, #1e2530); + border-radius: 6px; +} + +.selected-file svg { + color: var(--arkham-success, #22c55e); + flex-shrink: 0; +} + +.selected-file .file-info { + flex: 1; + min-width: 0; +} + +.selected-file .file-name { + display: block; + font-weight: 500; + color: var(--color-text-primary, #e5e7eb); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selected-file .file-size { + display: block; + font-size: 0.75rem; + color: var(--color-text-secondary, #9ca3af); +} + +.btn-ghost { + background: transparent; + border: 1px solid transparent; + color: var(--color-text-secondary, #9ca3af); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--color-bg-hover, #252d38); + color: var(--color-text-primary, #e5e7eb); +} + +/* Reverse Image Search URLs */ +.reverse-search-urls { + padding: 1rem; + background: var(--color-bg-secondary, #1e2530); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 8px; +} + +.reverse-search-urls .panel-section-header { + margin-bottom: 1rem; +} + +.reverse-search-urls .section-subtitle { + margin-left: auto; + font-size: 0.75rem; + color: var(--color-text-tertiary, #6b7280); + font-weight: 400; +} + +.search-urls-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.75rem; +} + +.search-url-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--color-bg-primary, #151a21); + border: 1px solid var(--color-border, #3f4a5a); + border-radius: 6px; + text-decoration: none; + transition: all 0.15s ease; +} + +.search-url-card:hover { + border-color: var(--color-primary, #3b82f6); + background: rgba(59, 130, 246, 0.05); + transform: translateY(-1px); +} + +.search-url-card.url_search { + border-left: 3px solid var(--arkham-success, #22c55e); +} + +.search-url-card.upload_search { + border-left: 3px solid var(--arkham-warning, #eab308); +} + +.search-url-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--color-bg-tertiary, #2a323e); + border-radius: 6px; + color: var(--color-text-secondary, #9ca3af); +} + +.search-url-card:hover .search-url-icon { + color: var(--color-primary, #3b82f6); +} + +.search-url-info { + flex: 1; + min-width: 0; +} + +.search-url-engine { + display: block; + font-weight: 500; + font-size: 0.875rem; + color: var(--color-text-primary, #e5e7eb); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-url-type { + display: block; + font-size: 0.7rem; + color: var(--color-text-tertiary, #6b7280); +} + +.search-url-card.url_search .search-url-type { + color: var(--arkham-success, #22c55e); +} + +.search-url-card.upload_search .search-url-type { + color: var(--arkham-warning, #eab308); +} + +.search-url-card .external-icon { + color: var(--color-text-tertiary, #6b7280); + flex-shrink: 0; +} + +.search-url-card:hover .external-icon { + color: var(--color-primary, #3b82f6); +} + +.search-url-note { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.2); + border-radius: 4px; + font-size: 0.75rem; + color: #fbbf24; +} + +.search-url-note svg { + flex-shrink: 0; +} + +/* Panel info message */ +.panel-info { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: 6px; + color: #60a5fa; + font-size: 0.875rem; +} + +.panel-info svg { + flex-shrink: 0; +} + +.panel-info p { + margin: 0; +} diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/MediaForensicsPage.tsx b/packages/arkham-shard-shell/src/pages/media-forensics/MediaForensicsPage.tsx new file mode 100644 index 0000000..b51134f --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/MediaForensicsPage.tsx @@ -0,0 +1,866 @@ +/** + * MediaForensicsPage - Media Forensics Analysis + * + * Main page for analyzing images and media files for authenticity, + * including EXIF metadata, C2PA credentials, ELA, and more. + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Icon } from '../../components/common/Icon'; +import { useToast } from '../../context/ToastContext'; +import { useFetch } from '../../hooks/useFetch'; +import { AIAnalystButton } from '../../components/AIAnalyst'; + +import * as api from './api'; +import { ExifPanel } from './components/ExifPanel'; +import { C2PAPanel } from './components/C2PAPanel'; +import { ELAViewer } from './components/ELAViewer'; +import { SunPositionPanel } from './components/SunPositionPanel'; +import { SimilarImagesPanel } from './components/SimilarImagesPanel'; +import { WarningsPanel } from './components/WarningsPanel'; + +import type { + MediaAnalysis, + AnalysisStats, + AnalysisStatus, + VerificationStatus, +} from './types'; +import { + STATUS_LABELS, + STATUS_COLORS, + VERIFICATION_LABELS, + VERIFICATION_COLORS, + STATUS_OPTIONS, + VERIFICATION_OPTIONS, +} from './types'; + +import './MediaForensicsPage.css'; + +// Detail tab types +type DetailTab = 'exif' | 'c2pa' | 'ela' | 'sun' | 'similar' | 'warnings'; + +// Document item for selection +interface DocumentItem { + id: string; + filename: string; + file_type: string; + status: string; +} + +export function MediaForensicsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const { toast } = useToast(); + + // State + const [analyses, setAnalyses] = useState([]); + const [selectedAnalysis, setSelectedAnalysis] = useState(null); + const [detailTab, setDetailTab] = useState('exif'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [statusFilter, setStatusFilter] = useState(''); + const [verificationFilter, setVerificationFilter] = useState(''); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); + + // Modal states + const [showAnalyzeModal, setShowAnalyzeModal] = useState(false); + const [selectedDocId, setSelectedDocId] = useState(''); + const [analyzing, setAnalyzing] = useState(false); + const [analyzeOptions, setAnalyzeOptions] = useState({ + run_ela: true, + run_sun_position: false, + run_similar_search: false, + }); + + // Upload states + const [uploadMode, setUploadMode] = useState<'select' | 'upload'>('upload'); + const [selectedFile, setSelectedFile] = useState(null); + const [dragActive, setDragActive] = useState(false); + + // Fetch stats + const { data: statsData, refetch: refetchStats } = useFetch<{ stats: AnalysisStats }>( + '/api/media-forensics/stats' + ); + const stats = statsData?.stats; + + // Fetch documents for selection + const { data: documentsData } = useFetch<{ + items: DocumentItem[]; + }>('/api/documents/items?status=processed&limit=100&file_type=image'); + const documents = documentsData?.items || []; + + // Fetch analyses + const fetchAnalyses = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await api.listAnalyses({ + offset, + limit: 20, + status: statusFilter || undefined, + verification_status: verificationFilter || undefined, + }); + setAnalyses(response.items); + setHasMore(response.has_more); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load analyses'); + } finally { + setLoading(false); + } + }, [offset, statusFilter, verificationFilter]); + + useEffect(() => { + fetchAnalyses(); + }, [fetchAnalyses]); + + // Reset offset when filters change + useEffect(() => { + setOffset(0); + }, [statusFilter, verificationFilter]); + + // Handle URL params for direct linking + useEffect(() => { + const analysisId = searchParams.get('id'); + if (analysisId && analyses.length > 0) { + const analysis = analyses.find((a) => a.id === analysisId); + if (analysis) { + setSelectedAnalysis(analysis); + } + } + }, [searchParams, analyses]); + + const handleSelectAnalysis = useCallback( + (analysis: MediaAnalysis) => { + setSelectedAnalysis(analysis); + setDetailTab('exif'); + setSearchParams({ id: analysis.id }); + }, + [setSearchParams] + ); + + const handleCloseDetail = useCallback(() => { + setSelectedAnalysis(null); + setSearchParams({}); + }, [setSearchParams]); + + const handleAnalyzeDocument = async () => { + // Validate input based on mode + if (uploadMode === 'upload' && !selectedFile) { + toast.error('Please select a file to upload'); + return; + } + if (uploadMode === 'select' && !selectedDocId) { + toast.error('Please select a document to analyze'); + return; + } + + setAnalyzing(true); + try { + let analysis: MediaAnalysis | undefined; + + if (uploadMode === 'upload' && selectedFile) { + // Upload file directly + analysis = await api.uploadAndAnalyze(selectedFile, { + run_ela: analyzeOptions.run_ela, + }); + toast.success('File uploaded and analyzed successfully'); + } else { + // Analyze existing document + const response = await api.analyzeDocument({ + doc_id: selectedDocId, + ...analyzeOptions, + }); + analysis = response.analysis; + toast.success('Analysis started successfully'); + } + + setShowAnalyzeModal(false); + setSelectedDocId(''); + setSelectedFile(null); + fetchAnalyses(); + refetchStats(); + + // Select the new analysis + if (analysis) { + setSelectedAnalysis(analysis); + setSearchParams({ id: analysis.id }); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to start analysis'); + } finally { + setAnalyzing(false); + } + }; + + // Handle file drop + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragActive(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + // Validate file type + const validTypes = ['image/jpeg', 'image/png', 'image/tiff', 'image/webp', 'image/gif', 'image/bmp']; + if (validTypes.includes(file.type) || /\.(jpg|jpeg|png|tiff|tif|webp|gif|bmp)$/i.test(file.name)) { + setSelectedFile(file); + } else { + toast.error('Unsupported file type. Please upload JPEG, PNG, TIFF, WebP, GIF, or BMP.'); + } + } + }, [toast]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragActive(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragActive(false); + }, []); + + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + setSelectedFile(files[0]); + } + }, []); + + const handleDeleteAnalysis = async (analysisId: string) => { + if (!confirm('Are you sure you want to delete this analysis?')) { + return; + } + + try { + await api.deleteAnalysis(analysisId); + toast.success('Analysis deleted'); + fetchAnalyses(); + refetchStats(); + if (selectedAnalysis?.id === analysisId) { + setSelectedAnalysis(null); + setSearchParams({}); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete analysis'); + } + }; + + const handleReanalyze = async (analysisId: string) => { + try { + const response = await api.reanalyze(analysisId); + toast.success('Re-analysis started'); + fetchAnalyses(); + if (response.analysis) { + setSelectedAnalysis(response.analysis); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to start re-analysis'); + } + }; + + const getVerificationIcon = (status: VerificationStatus): string => { + switch (status) { + case 'verified': + return 'CheckCircle'; + case 'flagged': + return 'AlertTriangle'; + case 'tampered': + return 'XCircle'; + default: + return 'HelpCircle'; + } + }; + + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + const formatDate = (dateStr: string): string => { + try { + return new Date(dateStr).toLocaleString(); + } catch { + return dateStr; + } + }; + + // Build AI Analyst context from selected analysis + const aiAnalystContext = useMemo(() => { + if (!selectedAnalysis) return {}; + + return { + filename: selectedAnalysis.filename, + file_type: selectedAnalysis.file_type, + file_size: selectedAnalysis.file_size, + verification_status: selectedAnalysis.verification_status, + integrity_status: selectedAnalysis.integrity_status, + findings_count: selectedAnalysis.findings_count, + critical_findings: selectedAnalysis.critical_findings, + exif_data: selectedAnalysis.exif_data, + c2pa_data: selectedAnalysis.c2pa_data, + ela_result: selectedAnalysis.ela_result, + perceptual_hashes: selectedAnalysis.perceptual_hashes, + analyzed_at: selectedAnalysis.analyzed_at, + }; + }, [selectedAnalysis]); + + if (loading && analyses.length === 0) { + return ( +
+
+ + Loading analyses... +
+
+ ); + } + + if (error && analyses.length === 0) { + return ( +
+
+ +

Failed to load analyses

+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+ +
+

Media Forensics

+

+ Analyze images for authenticity, metadata, and manipulation +

+
+
+
+ +
+
+ + {/* Stats Dashboard */} + {stats && ( +
+
+ +
{stats.total_analyses}
+
Total Analyses
+
+
+ +
{stats.with_c2pa}
+
With C2PA
+
+
+ +
{stats.with_findings}
+
With Findings
+
+
+ +
{stats.critical_findings_total}
+
Critical Issues
+
+
+ )} + + {/* Filters */} +
+
+ + +
+ +
+ + +
+ + +
+ +
+ {/* Analysis List */} +
+ {analyses.length === 0 ? ( +
+ +

No Analyses Found

+

Analyze an image to detect manipulation and verify authenticity.

+ +
+ ) : ( +
+ {analyses.map((analysis) => ( +
handleSelectAnalysis(analysis)} + > + {selectedAnalysis?.id === analysis.id && ( +
+ )} + +
+
+ +
+ {analysis.filename} + + {analysis.file_type} - {formatFileSize(analysis.file_size)} + +
+
+
+ + {STATUS_LABELS[analysis.status as AnalysisStatus]} + + + + {VERIFICATION_LABELS[analysis.verification_status as VerificationStatus]} + +
+
+ +
+ {analysis.exif_data && ( + + + EXIF + + )} + {analysis.c2pa_data?.has_manifest && ( + + + C2PA + + )} + {analysis.ela_result && ( + + + ELA + + )} + {analysis.exif_data?.gps?.latitude && ( + + + GPS + + )} +
+ + {analysis.findings_count > 0 && ( +
+ + + {analysis.findings_count} finding{analysis.findings_count !== 1 ? 's' : ''} + {analysis.critical_findings > 0 && ( + + ({analysis.critical_findings} critical) + + )} + +
+ )} + +
+ + + {formatDate(analysis.analyzed_at)} + +
+
+ ))} +
+ )} + + {/* Pagination */} + {(offset > 0 || hasMore) && ( +
+ + +
+ )} +
+ + {/* Detail Panel */} + {selectedAnalysis && ( +
+
+

{selectedAnalysis.filename}

+
+ + + + +
+
+ + {/* Verification Status Banner */} +
+ + + {VERIFICATION_LABELS[selectedAnalysis.verification_status as VerificationStatus]} + +
+ + {/* Detail Tabs */} +
+ + + + + + +
+ + {/* Tab Content */} +
+ {detailTab === 'exif' && } + {detailTab === 'c2pa' && } + {detailTab === 'ela' && ( + + )} + {detailTab === 'sun' && } + {detailTab === 'similar' && ( + + )} + {detailTab === 'warnings' && } +
+
+ )} +
+ + {/* Analyze Modal */} + {showAnalyzeModal && ( +
setShowAnalyzeModal(false)}> +
e.stopPropagation()}> +
+

+ + New Media Analysis +

+ +
+
+ {/* Mode Toggle */} +
+ + +
+ + {uploadMode === 'upload' ? ( +
+ +
+ {selectedFile ? ( +
+ +
+ {selectedFile.name} + + {(selectedFile.size / 1024).toFixed(1)} KB + +
+ +
+ ) : ( +
+ +

Drag & drop an image here, or click to browse

+ + Supports: JPEG, PNG, TIFF, WebP, GIF, BMP + +
+ )} + + {!selectedFile && ( + + )} +
+
+ ) : ( +
+ + + {documents.length === 0 && ( + + No processed image documents found. Use "Upload File" to add new images. + + )} +
+ )} + +
+ +
+ + {uploadMode === 'select' && ( + <> + + + + )} +
+
+
+
+ + +
+
+
+ )} +
+ ); +} + +export default MediaForensicsPage; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/api.ts b/packages/arkham-shard-shell/src/pages/media-forensics/api.ts new file mode 100644 index 0000000..f5778f9 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/api.ts @@ -0,0 +1,320 @@ +/** + * Media Forensics API Service + * + * API client for the Media Forensics shard backend. + */ + +import type { + MediaAnalysis, + AnalysisListResponse, + AnalyzeDocumentRequest, + AnalyzeDocumentResponse, + GenerateELARequest, + GenerateELAResponse, + SunPositionRequest, + SunPositionResponse, + FindSimilarRequest, + FindSimilarResponse, + StatsResponse, + C2PASupportResponse, + ELAResult, +} from './types'; + +const API_PREFIX = '/api/media-forensics'; + +// Generic fetch wrapper with error handling +async function fetchAPI(endpoint: string, options?: RequestInit): Promise { + const response = await fetch(`${API_PREFIX}${endpoint}`, { + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + ...options, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: response.statusText })); + throw new Error(error.detail || error.message || `HTTP ${response.status}`); + } + + return response.json(); +} + +// ============================================ +// Analysis Operations +// ============================================ + +/** + * Get list of media analyses with optional filters + */ +export async function listAnalyses(filters?: { + offset?: number; + limit?: number; + status?: string; + verification_status?: string; + has_findings?: boolean; + doc_id?: string; +}): Promise { + const params = new URLSearchParams(); + if (filters?.offset !== undefined) params.set('offset', filters.offset.toString()); + if (filters?.limit !== undefined) params.set('limit', filters.limit.toString()); + if (filters?.status) params.set('status', filters.status); + if (filters?.verification_status) params.set('verification_status', filters.verification_status); + if (filters?.has_findings !== undefined) params.set('has_findings', filters.has_findings.toString()); + if (filters?.doc_id) params.set('doc_id', filters.doc_id); + + const query = params.toString(); + return fetchAPI(`/analyses${query ? `?${query}` : ''}`); +} + +/** + * Get a single analysis by ID + */ +export async function getAnalysis(analysisId: string): Promise<{ analysis: MediaAnalysis }> { + return fetchAPI<{ analysis: MediaAnalysis }>(`/analyses/${analysisId}`); +} + +/** + * Get analysis for a specific document + */ +export async function getDocumentAnalysis(docId: string): Promise<{ analysis: MediaAnalysis | null }> { + return fetchAPI<{ analysis: MediaAnalysis | null }>(`/document/${docId}`); +} + +/** + * Analyze a document for media forensics + */ +export async function analyzeDocument(request: AnalyzeDocumentRequest): Promise { + return fetchAPI('/analyze', { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Upload and analyze an image file directly + */ +export async function uploadAndAnalyze( + file: File, + options?: { run_ela?: boolean } +): Promise { + const formData = new FormData(); + formData.append('file', file); + + const params = new URLSearchParams(); + if (options?.run_ela) params.set('run_ela', 'true'); + + const query = params.toString(); + const response = await fetch(`${API_PREFIX}/upload${query ? `?${query}` : ''}`, { + method: 'POST', + body: formData, + // Note: Don't set Content-Type header - browser sets it with boundary for FormData + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: response.statusText })); + throw new Error(error.detail || error.message || `HTTP ${response.status}`); + } + + return response.json(); +} + +/** + * Re-analyze an existing analysis + */ +export async function reanalyze(analysisId: string): Promise { + return fetchAPI(`/${analysisId}/reanalyze`, { + method: 'POST', + }); +} + +/** + * Delete an analysis + */ +export async function deleteAnalysis(analysisId: string): Promise<{ success: boolean }> { + return fetchAPI<{ success: boolean }>(`/${analysisId}`, { + method: 'DELETE', + }); +} + +// ============================================ +// ELA (Error Level Analysis) Operations +// ============================================ + +/** + * Generate ELA for an analysis + */ +export async function generateELA(request: GenerateELARequest): Promise { + return fetchAPI('/ela', { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Get ELA result for an analysis + */ +export async function getELA(analysisId: string): Promise<{ result: ELAResult | null }> { + return fetchAPI<{ result: ELAResult | null }>(`/${analysisId}/ela`); +} + +/** + * Regenerate ELA with different quality settings + */ +export async function regenerateELA( + analysisId: string, + qualityLevel: number +): Promise { + return fetchAPI(`/${analysisId}/ela/regenerate`, { + method: 'POST', + body: JSON.stringify({ quality_level: qualityLevel }), + }); +} + +// ============================================ +// Sun Position Operations +// ============================================ + +/** + * Calculate sun position verification + */ +export async function getSunPosition(request: SunPositionRequest): Promise { + return fetchAPI('/sun-position', { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Get existing sun position analysis + */ +export async function getSunPositionResult( + analysisId: string +): Promise<{ result: SunPositionResponse['result'] | null }> { + return fetchAPI<{ result: SunPositionResponse['result'] | null }>(`/${analysisId}/sun-position`); +} + +// ============================================ +// Similar Images Operations +// ============================================ + +/** + * Find similar images + */ +export async function findSimilar(request: FindSimilarRequest): Promise { + return fetchAPI('/similar', { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Get existing similar images search results + */ +export async function getSimilarResults( + analysisId: string +): Promise<{ result: FindSimilarResponse['result'] | null }> { + return fetchAPI<{ result: FindSimilarResponse['result'] | null }>(`/${analysisId}/similar`); +} + +// ============================================ +// Statistics & Support +// ============================================ + +/** + * Get media forensics statistics + */ +export async function getStats(): Promise { + return fetchAPI('/stats'); +} + +/** + * Check if C2PA is supported + */ +export async function checkC2PASupport(): Promise { + return fetchAPI('/c2pa/supported'); +} + +// ============================================ +// Notes & Findings Operations +// ============================================ + +/** + * Add a note to an analysis + */ +export async function addNote( + analysisId: string, + content: string, + author: string +): Promise<{ success: boolean }> { + return fetchAPI<{ success: boolean }>(`/${analysisId}/notes`, { + method: 'POST', + body: JSON.stringify({ content, author }), + }); +} + +/** + * Update verification status + */ +export async function updateVerificationStatus( + analysisId: string, + status: string, + reason?: string +): Promise<{ analysis: MediaAnalysis }> { + return fetchAPI<{ analysis: MediaAnalysis }>(`/${analysisId}/verification`, { + method: 'PUT', + body: JSON.stringify({ status, reason }), + }); +} + +/** + * Dismiss a finding + */ +export async function dismissFinding( + analysisId: string, + findingId: string, + reason: string, + dismissedBy: string +): Promise<{ success: boolean }> { + return fetchAPI<{ success: boolean }>(`/${analysisId}/findings/${findingId}/dismiss`, { + method: 'POST', + body: JSON.stringify({ reason, dismissed_by: dismissedBy }), + }); +} + +// ============================================ +// Batch Operations +// ============================================ + +/** + * Analyze multiple documents + */ +export async function batchAnalyze( + docIds: string[], + options?: { + run_ela?: boolean; + run_sun_position?: boolean; + run_similar_search?: boolean; + } +): Promise<{ job_id: string; queued_count: number }> { + return fetchAPI<{ job_id: string; queued_count: number }>('/batch/analyze', { + method: 'POST', + body: JSON.stringify({ doc_ids: docIds, ...options }), + }); +} + +/** + * Get batch job status + */ +export async function getBatchStatus( + jobId: string +): Promise<{ + job_id: string; + status: string; + total: number; + completed: number; + failed: number; + results: Array<{ doc_id: string; analysis_id: string | null; error: string | null }>; +}> { + return fetchAPI(`/batch/${jobId}`); +} diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/components/C2PAPanel.tsx b/packages/arkham-shard-shell/src/pages/media-forensics/components/C2PAPanel.tsx new file mode 100644 index 0000000..a79f50e --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/components/C2PAPanel.tsx @@ -0,0 +1,304 @@ +/** + * C2PAPanel - Display Content Credentials (C2PA) information + */ + +import { Icon } from '../../../components/common/Icon'; +import type { MediaAnalysis, C2PAManifest, C2PAAction } from '../types'; +import { C2PA_VALIDATION_LABELS, C2PA_VALIDATION_COLORS } from '../types'; + +interface C2PAPanelProps { + analysis: MediaAnalysis; +} + +export function C2PAPanel({ analysis }: C2PAPanelProps) { + const { c2pa_data } = analysis; + + if (!c2pa_data || !c2pa_data.has_manifest) { + return ( +
+ +

No C2PA Content Credentials found

+

+ This image does not contain Content Authenticity Initiative (CAI) credentials +

+
+ ); + } + + const activeManifest = + c2pa_data.active_manifest_index !== null + ? c2pa_data.manifests[c2pa_data.active_manifest_index] + : c2pa_data.manifests[0]; + + const formatDate = (dateStr: string | null): string => { + if (!dateStr) return 'N/A'; + try { + return new Date(dateStr).toLocaleString(); + } catch { + return dateStr; + } + }; + + const getActionIcon = (action: string): string => { + const actionLower = action.toLowerCase(); + if (actionLower.includes('create') || actionLower.includes('captured')) return 'Camera'; + if (actionLower.includes('edit') || actionLower.includes('modified')) return 'Edit3'; + if (actionLower.includes('crop') || actionLower.includes('resize')) return 'Crop'; + if (actionLower.includes('filter') || actionLower.includes('adjust')) return 'Sliders'; + if (actionLower.includes('publish') || actionLower.includes('export')) return 'Upload'; + if (actionLower.includes('sign')) return 'Key'; + return 'Activity'; + }; + + const renderManifest = (manifest: C2PAManifest, index: number) => ( +
+ {/* Signer Information */} + {manifest.signer && ( +
+
+ +

Signed By

+
+
+
+ +
+
+
{manifest.signer.name}
+ {manifest.signer.organization && ( +
{manifest.signer.organization}
+ )} + {manifest.signer.issued_date && ( +
+ Issued: {formatDate(manifest.signer.issued_date)} +
+ )} +
+
+ {C2PA_VALIDATION_LABELS[manifest.signer.validation_status]} +
+
+ + {/* Trust Chain */} + {manifest.signer.trust_chain && manifest.signer.trust_chain.length > 0 && ( +
+ Trust Chain: + {manifest.signer.trust_chain.map((cert, idx) => ( + + {cert} + {idx < manifest.signer!.trust_chain.length - 1 && ( + + )} + + ))} +
+ )} +
+ )} + + {/* Manifest Info */} +
+
+ +

Manifest Details

+
+
+ {manifest.title && ( +
+ Title + {manifest.title} +
+ )} + {manifest.format && ( +
+ Format + {manifest.format} +
+ )} + {manifest.claim_generator && ( +
+ Generated By + {manifest.claim_generator} +
+ )} + {manifest.signature_date && ( +
+ Signed + {formatDate(manifest.signature_date)} +
+ )} + {manifest.instance_id && ( +
+ Instance ID + + {manifest.instance_id} + +
+ )} +
+
+ + {/* Actions History */} + {manifest.actions && manifest.actions.length > 0 && ( +
+
+ +

Edit History ({manifest.actions.length} actions)

+
+
+ {manifest.actions.map((action: C2PAAction, idx: number) => ( +
+
+ +
+
+
{action.action}
+
+ {action.software_agent && {action.software_agent}} + {action.when && - {formatDate(action.when)}} +
+
+
+ ))} +
+
+ )} + + {/* Ingredients (Parent Assets) */} + {manifest.ingredients && manifest.ingredients.length > 0 && ( +
+
+ +

Source Assets ({manifest.ingredients.length})

+
+
+ {manifest.ingredients.map((ingredient, idx) => ( +
+ {ingredient.thumbnail && ( + {ingredient.title} + )} +
+
{ingredient.title}
+
+ {ingredient.format} + {ingredient.relationship} +
+
+
+ ))} +
+
+ )} + + {/* Assertions */} + {manifest.assertions && manifest.assertions.length > 0 && ( +
+
+ +

Assertions ({manifest.assertions.length})

+
+
+ {manifest.assertions.map((assertion, idx) => ( +
+
{assertion.label}
+ {assertion.instance !== undefined && ( + #{assertion.instance} + )} +
+ ))} +
+
+ )} +
+ ); + + return ( +
+ {/* Validation Status Banner */} +
+ + + Content Credentials: {C2PA_VALIDATION_LABELS[c2pa_data.validation_status]} + +
+ + {/* Validation Errors */} + {c2pa_data.validation_errors && c2pa_data.validation_errors.length > 0 && ( +
+ {c2pa_data.validation_errors.map((error, idx) => ( +
+ + {error} +
+ ))} +
+ )} + + {/* Provenance Chain */} + {c2pa_data.provenance_chain && c2pa_data.provenance_chain.length > 0 && ( +
+
+ +

Provenance Chain

+
+
+ {c2pa_data.provenance_chain.map((step, idx) => ( +
+
+
+ {idx < c2pa_data.provenance_chain.length - 1 && ( +
+ )} +
+
{step}
+
+ ))} +
+
+ )} + + {/* Manifests */} + {activeManifest && renderManifest(activeManifest, 0)} + + {/* Multiple Manifests Notice */} + {c2pa_data.manifests.length > 1 && ( +
+ + + This image contains {c2pa_data.manifests.length} manifests. Showing active manifest. + +
+ )} +
+ ); +} + +export default C2PAPanel; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/components/ELAViewer.tsx b/packages/arkham-shard-shell/src/pages/media-forensics/components/ELAViewer.tsx new file mode 100644 index 0000000..83f7ea9 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/components/ELAViewer.tsx @@ -0,0 +1,287 @@ +/** + * ELAViewer - Error Level Analysis side-by-side viewer with controls + */ + +import { useState, useCallback } from 'react'; +import { Icon } from '../../../components/common/Icon'; +import { useToast } from '../../../context/ToastContext'; +import * as api from '../api'; +import type { MediaAnalysis, ELAResult } from '../types'; + +interface ELAViewerProps { + analysis: MediaAnalysis; + onRegenerate?: () => void; +} + +export function ELAViewer({ analysis, onRegenerate }: ELAViewerProps) { + const { toast } = useToast(); + const [elaResult, setElaResult] = useState(analysis.ela_result); + const [qualityLevel, setQualityLevel] = useState(95); + const [generating, setGenerating] = useState(false); + const [viewMode, setViewMode] = useState<'side-by-side' | 'ela-only' | 'overlay'>('side-by-side'); + + const handleGenerateELA = useCallback(async () => { + setGenerating(true); + try { + const response = await api.generateELA({ + analysis_id: analysis.id, + quality_level: qualityLevel, + }); + setElaResult(response.result); + toast.success('ELA generated successfully'); + onRegenerate?.(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to generate ELA'); + } finally { + setGenerating(false); + } + }, [analysis.id, qualityLevel, toast, onRegenerate]); + + const handleRegenerateELA = useCallback(async () => { + setGenerating(true); + try { + const response = await api.regenerateELA(analysis.id, qualityLevel); + setElaResult(response.result); + toast.success('ELA regenerated with new quality level'); + onRegenerate?.(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to regenerate ELA'); + } finally { + setGenerating(false); + } + }, [analysis.id, qualityLevel, toast, onRegenerate]); + + // No ELA yet + if (!elaResult) { + return ( +
+
+ +

Error Level Analysis not yet performed

+

+ ELA can help detect edited or manipulated regions in JPEG images +

+
+ +
+ + setQualityLevel(Number(e.target.value))} + disabled={generating} + /> + {qualityLevel}% +
+ + +
+ ); + } + + return ( +
+ {/* Result Summary */} +
+ +
+ + {elaResult.is_potentially_edited + ? 'Potential Editing Detected' + : 'No Obvious Manipulation'} + + + Confidence: {(elaResult.confidence * 100).toFixed(0)}% + +
+
+ + {/* View Mode Toggle */} +
+ + + +
+ + {/* Image Comparison */} +
+ {viewMode !== 'ela-only' && ( +
+ Original + {/* Use the image serving endpoint for the original image */} + Original { + // Hide the image on error and show placeholder + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const placeholder = target.nextElementSibling; + if (placeholder) (placeholder as HTMLElement).style.display = 'flex'; + }} + /> +
+ + Original not available +
+
+ )} +
+ ELA (Q={elaResult.quality_level}) + {elaResult.ela_image_url ? ( + ELA + ) : elaResult.ela_image_base64 ? ( + ELA + ) : ( +
+ + ELA not available +
+ )} +
+
+ + {/* Intensity Metrics */} +
+
+ +

Intensity Metrics

+
+
+
+ Global Average + {elaResult.global_avg_intensity.toFixed(2)} +
+
+ Global Maximum + {elaResult.global_max_intensity.toFixed(2)} +
+
+ Quality Level + {elaResult.quality_level}% +
+
+ Suspicious Regions + {elaResult.suspicious_regions.length} +
+
+
+ + {/* Suspicious Regions */} + {elaResult.suspicious_regions.length > 0 && ( +
+
+ +

Suspicious Regions ({elaResult.suspicious_regions.length})

+
+
+ {elaResult.suspicious_regions.map((region, idx) => ( +
+
+ + Region {idx + 1}: ({region.x}, {region.y}) {region.width}x{region.height} + + {region.description} +
+
+ Avg: {region.avg_intensity.toFixed(1)} + Max: {region.max_intensity.toFixed(1)} +
+
+ ))} +
+
+ )} + + {/* Regenerate Controls */} +
+ + setQualityLevel(Number(e.target.value))} + disabled={generating} + /> + {qualityLevel}% + +
+ + {/* ELA Explanation */} +
+ +

+ How to read ELA: Areas with high intensity (bright spots) have been + saved at a different quality level than the rest of the image, which may indicate + manipulation. Uniform intensity usually indicates an unedited image. +

+
+
+ ); +} + +export default ELAViewer; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/components/ExifPanel.tsx b/packages/arkham-shard-shell/src/pages/media-forensics/components/ExifPanel.tsx new file mode 100644 index 0000000..5c7ced1 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/components/ExifPanel.tsx @@ -0,0 +1,351 @@ +/** + * ExifPanel - Display EXIF metadata in organized grid + */ + +import { Icon } from '../../../components/common/Icon'; +import type { MediaAnalysis } from '../types'; + +interface ExifPanelProps { + analysis: MediaAnalysis; +} + +export function ExifPanel({ analysis }: ExifPanelProps) { + const { exif_data } = analysis; + + if (!exif_data) { + return ( +
+ +

No EXIF data available for this image

+

+ EXIF data may have been stripped or the image format does not support metadata +

+
+ ); + } + + const formatDate = (dateStr: string | null): string => { + if (!dateStr) return 'N/A'; + try { + return new Date(dateStr).toLocaleString(); + } catch { + return dateStr; + } + }; + + const formatGPS = (lat: number | null, lon: number | null): string => { + if (lat === null || lon === null) return 'N/A'; + const latDir = lat >= 0 ? 'N' : 'S'; + const lonDir = lon >= 0 ? 'E' : 'W'; + return `${Math.abs(lat).toFixed(6)}${latDir}, ${Math.abs(lon).toFixed(6)}${lonDir}`; + }; + + const { camera, image, gps, capture, timestamps, software } = exif_data; + + return ( +
+ {/* Warnings */} + {exif_data.warnings && exif_data.warnings.length > 0 && ( +
+
+ +

Warnings

+
+
+ {exif_data.warnings.map((warning, idx) => ( +
+ + {warning} +
+ ))} +
+
+ )} + + {/* Camera Information */} + {(camera.make || camera.model || camera.serial_number) && ( +
+
+ +

Camera Information

+
+
+ {camera.make && ( +
+ Make + {camera.make} +
+ )} + {camera.model && ( +
+ Model + {camera.model} +
+ )} + {camera.serial_number && ( +
+ Serial Number + {camera.serial_number} +
+ )} + {camera.lens_make && ( +
+ Lens Make + {camera.lens_make} +
+ )} + {camera.lens_model && ( +
+ Lens Model + {camera.lens_model} +
+ )} + {camera.lens_serial && ( +
+ Lens Serial + {camera.lens_serial} +
+ )} +
+
+ )} + + {/* Image Properties */} + {(image.width || image.height || image.color_space) && ( +
+
+ +

Image Properties

+
+
+ {image.width && image.height && ( +
+ Dimensions + + {image.width} x {image.height} px + +
+ )} + {image.color_space && ( +
+ Color Space + {image.color_space} +
+ )} + {image.bits_per_sample && ( +
+ Bit Depth + {image.bits_per_sample} bits +
+ )} + {image.compression && ( +
+ Compression + {image.compression} +
+ )} + {image.orientation && ( +
+ Orientation + {image.orientation} +
+ )} +
+
+ )} + + {/* GPS Location */} + {(gps.latitude !== null || gps.longitude !== null) && ( +
+
+ +

GPS Location

+
+
+
+ Coordinates + {formatGPS(gps.latitude, gps.longitude)} +
+ {gps.altitude !== null && ( +
+ Altitude + + {gps.altitude.toFixed(1)} m {gps.altitude_ref || ''} + +
+ )} + {gps.direction !== null && ( +
+ Direction + + {gps.direction.toFixed(1)}{gps.direction_ref || ''} + +
+ )} + {gps.gps_timestamp && ( +
+ GPS Time + {formatDate(gps.gps_timestamp)} +
+ )} + {gps.gps_date && ( +
+ GPS Date + {gps.gps_date} +
+ )} +
+
+ + + Map view: {gps.latitude?.toFixed(4)}, {gps.longitude?.toFixed(4)} + +
+
+ )} + + {/* Capture Settings */} + {(capture.exposure_time || capture.f_number || capture.iso) && ( +
+
+ +

Capture Settings

+
+
+ {capture.exposure_time && ( +
+ Exposure + {capture.exposure_time} +
+ )} + {capture.f_number !== null && ( +
+ Aperture + f/{capture.f_number} +
+ )} + {capture.iso !== null && ( +
+ ISO + {capture.iso} +
+ )} + {capture.focal_length !== null && ( +
+ Focal Length + + {capture.focal_length}mm + {capture.focal_length_35mm && ` (${capture.focal_length_35mm}mm eq.)`} + +
+ )} + {capture.exposure_mode && ( +
+ Exposure Mode + {capture.exposure_mode} +
+ )} + {capture.exposure_program && ( +
+ Program + {capture.exposure_program} +
+ )} + {capture.metering_mode && ( +
+ Metering + {capture.metering_mode} +
+ )} + {capture.white_balance && ( +
+ White Balance + {capture.white_balance} +
+ )} + {capture.flash && ( +
+ Flash + {capture.flash} +
+ )} +
+
+ )} + + {/* Timestamps */} + {(timestamps.datetime_original || timestamps.datetime_digitized || timestamps.datetime_modified) && ( +
+
+ +

Timestamps

+
+
+ {timestamps.datetime_original && ( +
+ Original Date + {formatDate(timestamps.datetime_original)} +
+ )} + {timestamps.datetime_digitized && ( +
+ Digitized + {formatDate(timestamps.datetime_digitized)} +
+ )} + {timestamps.datetime_modified && ( +
+ Modified + {formatDate(timestamps.datetime_modified)} +
+ )} + {timestamps.timezone_offset && ( +
+ Timezone + {timestamps.timezone_offset} +
+ )} +
+
+ )} + + {/* Software */} + {(software.software || software.processing_software || software.host_computer) && ( +
+
+ +

Software

+
+
+ {software.software && ( +
+ Software + {software.software} +
+ )} + {software.processing_software && ( +
+ Processing Software + {software.processing_software} +
+ )} + {software.host_computer && ( +
+ Host Computer + {software.host_computer} +
+ )} + {software.firmware && ( +
+ Firmware + {software.firmware} +
+ )} +
+
+ )} +
+ ); +} + +export default ExifPanel; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/components/SimilarImagesPanel.tsx b/packages/arkham-shard-shell/src/pages/media-forensics/components/SimilarImagesPanel.tsx new file mode 100644 index 0000000..8ad7fd4 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/components/SimilarImagesPanel.tsx @@ -0,0 +1,382 @@ +/** + * SimilarImagesPanel - Grid display of similar/duplicate images + */ + +import { useState, useCallback } from 'react'; +import { Icon } from '../../../components/common/Icon'; +import { useToast } from '../../../context/ToastContext'; +import * as api from '../api'; +import type { MediaAnalysis, SimilarImagesResult, SimilarImage } from '../types'; + +interface SimilarImagesPanelProps { + analysis: MediaAnalysis; + onSearch?: () => void; +} + +export function SimilarImagesPanel({ analysis, onSearch }: SimilarImagesPanelProps) { + const { toast } = useToast(); + const [result, setResult] = useState( + analysis.similar_images_result + ); + const [searching, setSearching] = useState(false); + const [searchType, setSearchType] = useState<'internal' | 'external' | 'both'>('internal'); + const [selectedImage, setSelectedImage] = useState(null); + + const handleSearch = useCallback(async () => { + setSearching(true); + try { + const response = await api.findSimilar({ + analysis_id: analysis.id, + search_type: searchType, + limit: 50, + }); + setResult(response.result); + toast.success( + `Found ${response.result.total_found} similar image${response.result.total_found !== 1 ? 's' : ''}` + ); + onSearch?.(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to search for similar images'); + } finally { + setSearching(false); + } + }, [analysis.id, searchType, toast, onSearch]); + + const getSimilarityTypeLabel = (type: SimilarImage['similarity_type']): string => { + switch (type) { + case 'exact': + return 'Exact Match'; + case 'near_duplicate': + return 'Near Duplicate'; + case 'visually_similar': + return 'Visually Similar'; + case 'content_similar': + return 'Content Similar'; + default: + return type; + } + }; + + const getSimilarityTypeColor = (type: SimilarImage['similarity_type']): string => { + switch (type) { + case 'exact': + return 'var(--arkham-error, #ef4444)'; + case 'near_duplicate': + return 'var(--arkham-warning, #f97316)'; + case 'visually_similar': + return 'var(--arkham-warning, #eab308)'; + case 'content_similar': + return 'var(--arkham-info, #3b82f6)'; + default: + return 'var(--color-text-muted)'; + } + }; + + // No search performed yet + if (!result) { + return ( +
+
+ +

Similar image search not yet performed

+

+ Search for visually similar or duplicate images in the document collection +

+
+ +
+
+ +

Search Options

+
+
+ + + +
+
+ + +
+ ); + } + + return ( +
+ {/* Summary Stats */} +
+
+ {result.total_found} + Total Found +
+
+ + {result.exact_matches} + + Exact Matches +
+
+ + {result.near_duplicates} + + Near Duplicates +
+
+ + {result.visually_similar} + + Visually Similar +
+
+ + {/* Search URLs for Manual Reverse Image Search */} + {result.search_urls && result.search_urls.length > 0 && ( +
+
+ +

Reverse Image Search

+ Click to search on external engines +
+ + {result.search_urls.some(u => u.type === 'upload_search') && ( +

+ + For "Upload Required" links, download the image first, then upload it to the search engine. +

+ )} +
+ )} + + {/* No Results */} + {result.similar_images.length === 0 && !result.search_urls?.length && ( +
+ +

No similar images found

+

+ This image appears to be unique in the searched collection +

+
+ )} + + {result.similar_images.length === 0 && (result.search_urls?.length ?? 0) > 0 && ( +
+ +

No automatic results found. Use the reverse image search links above to search manually.

+
+ )} + + {/* Results Grid */} + {result.similar_images.length > 0 && ( +
+ {result.similar_images.map((image) => ( +
setSelectedImage(selectedImage?.id === image.id ? null : image)} + > + {/* Thumbnail */} + {image.thumbnail_url ? ( + {image.filename} + ) : image.thumbnail_base64 ? ( + {image.filename} + ) : ( +
+ +
+ )} + + {/* Similarity Badge */} +
+ {Math.round(image.similarity_score * 100)}% +
+ + {/* Info */} +
+
+ {getSimilarityTypeLabel(image.similarity_type)} +
+
{image.filename}
+
+
+ ))} +
+ )} + + {/* Selected Image Details */} + {selectedImage && ( +
+
+ +

Match Details

+ +
+
+
+ Filename + {selectedImage.filename} +
+
+ Similarity + + {(selectedImage.similarity_score * 100).toFixed(1)}% + +
+
+ Type + + {getSimilarityTypeLabel(selectedImage.similarity_type)} + +
+ {selectedImage.source && ( +
+ Source + {selectedImage.source} +
+ )} + {selectedImage.match_details.hash_distance !== undefined && ( +
+ Hash Distance + {selectedImage.match_details.hash_distance} +
+ )} + {selectedImage.match_details.feature_similarity !== undefined && ( +
+ Feature Similarity + + {(selectedImage.match_details.feature_similarity * 100).toFixed(1)}% + +
+ )} +
+ Found At + + {new Date(selectedImage.found_at).toLocaleString()} + +
+
+
+ )} + + {/* Re-search Controls */} +
+
+ +
+ +
+
+ ); +} + +export default SimilarImagesPanel; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/components/SunPositionPanel.tsx b/packages/arkham-shard-shell/src/pages/media-forensics/components/SunPositionPanel.tsx new file mode 100644 index 0000000..83c89ed --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/components/SunPositionPanel.tsx @@ -0,0 +1,382 @@ +/** + * SunPositionPanel - Sun position and shadow verification display + */ + +import { useState, useCallback } from 'react'; +import { Icon } from '../../../components/common/Icon'; +import { useToast } from '../../../context/ToastContext'; +import * as api from '../api'; +import type { MediaAnalysis, SunPositionResult, SunPositionRequest } from '../types'; + +interface SunPositionPanelProps { + analysis: MediaAnalysis; +} + +export function SunPositionPanel({ analysis }: SunPositionPanelProps) { + const { toast } = useToast(); + const [sunResult, setSunResult] = useState( + analysis.sun_position_result + ); + const [calculating, setCalculating] = useState(false); + const [overrideLocation, setOverrideLocation] = useState<{ lat: string; lon: string }>({ + lat: '', + lon: '', + }); + const [overrideTime, setOverrideTime] = useState(''); + + const hasGPS = analysis.exif_data?.gps?.latitude !== null; + + const handleCalculate = useCallback(async () => { + setCalculating(true); + try { + const request: SunPositionRequest = { + analysis_id: analysis.id, + }; + + // Add overrides if provided + if (overrideLocation.lat && overrideLocation.lon) { + request.override_location = { + lat: parseFloat(overrideLocation.lat), + lon: parseFloat(overrideLocation.lon), + }; + } + if (overrideTime) { + request.override_time = overrideTime; + } + + const response = await api.getSunPosition(request); + setSunResult(response.result); + toast.success('Sun position calculated'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to calculate sun position'); + } finally { + setCalculating(false); + } + }, [analysis.id, overrideLocation, overrideTime, toast]); + + // No GPS data + if (!hasGPS && !sunResult) { + return ( +
+
+ +

No GPS data available for sun position verification

+

+ Sun position analysis requires GPS coordinates and timestamp from the image metadata +

+
+ + {/* Manual Override Form */} +
+
+ +

Manual Coordinates

+
+

+ Enter coordinates manually to verify sun position for a claimed location and time. +

+
+
+ + + setOverrideLocation({ ...overrideLocation, lat: e.target.value }) + } + /> +
+
+ + + setOverrideLocation({ ...overrideLocation, lon: e.target.value }) + } + /> +
+
+ + setOverrideTime(e.target.value)} + /> +
+
+ +
+
+ ); + } + + // No result yet but has GPS + if (!sunResult) { + return ( +
+
+ +

Sun position verification not yet performed

+
+ +
+ ); + } + + const formatAngle = (angle: number): string => { + return `${angle.toFixed(2)}`; + }; + + return ( +
+ {/* Consistency Banner */} +
+ +
+
+ {sunResult.is_consistent + ? 'Sun Position Consistent' + : 'Sun Position Inconsistent'} +
+
+ Confidence: {(sunResult.confidence * 100).toFixed(0)}% +
+
+
+ + {/* Inconsistency Details */} + {!sunResult.is_consistent && sunResult.inconsistency_details.length > 0 && ( +
+ {sunResult.inconsistency_details.map((detail, idx) => ( +
+ + {detail} +
+ ))} +
+ )} + + {/* Claimed vs Calculated */} +
+ {/* Claimed Data */} +
+
+ + Claimed Location +
+ {sunResult.claimed_location ? ( +
+
+ Lat: + {sunResult.claimed_location.lat.toFixed(6)} +
+
+ Lon: + {sunResult.claimed_location.lon.toFixed(6)} +
+
+ ) : ( + Not available + )} + {sunResult.claimed_time && ( +
+ + {new Date(sunResult.claimed_time).toLocaleString()} +
+ )} +
+ + {/* Calculated Sun Position */} + {sunResult.calculated_sun_position && ( +
+
+ + Calculated Sun +
+
+
+ Azimuth + + {formatAngle(sunResult.calculated_sun_position.azimuth)} + (deg) + +
+
+ Altitude + + {formatAngle(sunResult.calculated_sun_position.altitude)} + (deg) + +
+
+
+ )} +
+ + {/* Shadow Analysis */} + {sunResult.shadow_analysis && ( +
+
+ +

Shadow Analysis

+
+ + {/* Consistency Score */} +
+ Shadow Consistency Score: +
+
0.7 + ? 'var(--arkham-success)' + : sunResult.shadow_analysis.consistency_score > 0.4 + ? 'var(--arkham-warning)' + : 'var(--arkham-error)', + }} + /> +
+ + {(sunResult.shadow_analysis.consistency_score * 100).toFixed(0)}% + +
+ + {/* Average Shadow Direction */} + {sunResult.shadow_analysis.average_shadow_direction !== null && ( +
+ Average Shadow Direction + + {sunResult.shadow_analysis.average_shadow_direction.toFixed(1)} (degrees) + +
+ )} + + {/* Detected Shadows */} + {sunResult.shadow_analysis.detected_shadows.length > 0 && ( +
+
Detected Shadows ({sunResult.shadow_analysis.detected_shadows.length})
+
+ {sunResult.shadow_analysis.detected_shadows.map((shadow, idx) => ( +
+ {shadow.object_id} + + {shadow.shadow_direction.toFixed(1)} (deg) + + + {(shadow.confidence * 100).toFixed(0)}% + +
+ ))} +
+
+ )} +
+ )} + + {/* Recalculate with Overrides */} +
+
+ +

Recalculate with Custom Values

+
+
+
+ + + setOverrideLocation({ ...overrideLocation, lat: e.target.value }) + } + /> +
+
+ + + setOverrideLocation({ ...overrideLocation, lon: e.target.value }) + } + /> +
+
+ + setOverrideTime(e.target.value)} + /> +
+
+ +
+
+ ); +} + +export default SunPositionPanel; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/components/WarningsPanel.tsx b/packages/arkham-shard-shell/src/pages/media-forensics/components/WarningsPanel.tsx new file mode 100644 index 0000000..68ee1b8 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/components/WarningsPanel.tsx @@ -0,0 +1,235 @@ +/** + * WarningsPanel - Display metadata warnings and forensic findings + */ + +import { Icon } from '../../../components/common/Icon'; +import type { MediaAnalysis, ForensicFinding, FindingSeverity } from '../types'; +import { SEVERITY_LABELS, SEVERITY_COLORS } from '../types'; + +interface WarningsPanelProps { + analysis: MediaAnalysis; +} + +export function WarningsPanel({ analysis }: WarningsPanelProps) { + const { findings } = analysis; + + if (!findings || findings.length === 0) { + return ( +
+ +

No warnings or findings detected

+

+ The analysis did not identify any suspicious indicators in this image +

+
+ ); + } + + // Group findings by category + const groupedFindings = findings.reduce( + (acc, finding) => { + if (!acc[finding.category]) { + acc[finding.category] = []; + } + acc[finding.category].push(finding); + return acc; + }, + {} as Record + ); + + // Sort findings by severity within each category + const severityOrder: FindingSeverity[] = ['critical', 'high', 'medium', 'low', 'info']; + Object.keys(groupedFindings).forEach((category) => { + groupedFindings[category].sort( + (a, b) => severityOrder.indexOf(a.severity) - severityOrder.indexOf(b.severity) + ); + }); + + const getCategoryIcon = (category: string): string => { + switch (category) { + case 'exif': + return 'Camera'; + case 'c2pa': + return 'ShieldCheck'; + case 'ela': + return 'Layers'; + case 'sun_position': + return 'Sun'; + case 'similar_images': + return 'Images'; + default: + return 'AlertTriangle'; + } + }; + + const getCategoryLabel = (category: string): string => { + switch (category) { + case 'exif': + return 'EXIF Metadata'; + case 'c2pa': + return 'Content Credentials'; + case 'ela': + return 'Error Level Analysis'; + case 'sun_position': + return 'Sun Position'; + case 'similar_images': + return 'Similar Images'; + default: + return 'General'; + } + }; + + const getSeverityIcon = (severity: FindingSeverity): string => { + switch (severity) { + case 'critical': + return 'XCircle'; + case 'high': + return 'AlertTriangle'; + case 'medium': + return 'AlertCircle'; + case 'low': + return 'Info'; + default: + return 'HelpCircle'; + } + }; + + const formatDate = (dateStr: string): string => { + try { + return new Date(dateStr).toLocaleString(); + } catch { + return dateStr; + } + }; + + // Count by severity + const severityCounts = findings.reduce( + (acc, f) => { + acc[f.severity] = (acc[f.severity] || 0) + 1; + return acc; + }, + {} as Record + ); + + return ( +
+ {/* Summary */} +
+
+ + + {findings.length} finding{findings.length !== 1 ? 's' : ''} detected + +
+
+ {severityOrder.map( + (severity) => + severityCounts[severity] > 0 && ( + + {severityCounts[severity]} {SEVERITY_LABELS[severity]} + + ) + )} +
+
+ + {/* Grouped Findings */} + {Object.entries(groupedFindings).map(([category, categoryFindings]) => ( +
+
+ +

+ {getCategoryLabel(category)} ({categoryFindings.length}) +

+
+
+ {categoryFindings.map((finding) => ( +
+
+
+ + {finding.title} +
+ + {SEVERITY_LABELS[finding.severity]} + +
+

{finding.description}

+ + {/* Recommendation */} + {finding.recommendation && ( +
+ + {finding.recommendation} +
+ )} + + {/* Evidence */} + {finding.evidence && Object.keys(finding.evidence).length > 0 && ( +
+
+ + + Evidence Details + +
{JSON.stringify(finding.evidence, null, 2)}
+
+
+ )} + +
+ + + {(finding.confidence * 100).toFixed(0)}% confidence + + + {finding.auto_detected ? ( + <> + + Auto-detected + + ) : ( + <> + + Manual + + )} + + + + {formatDate(finding.detected_at)} + +
+
+ ))} +
+
+ ))} + + {/* Legend */} +
+
Severity Legend
+
+ {severityOrder.map((severity) => ( +
+
+ {SEVERITY_LABELS[severity]} +
+ ))} +
+
+
+ ); +} + +export default WarningsPanel; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/components/index.ts b/packages/arkham-shard-shell/src/pages/media-forensics/components/index.ts new file mode 100644 index 0000000..453488c --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/components/index.ts @@ -0,0 +1,12 @@ +/** + * Media Forensics Components + * + * Export all panel components for the Media Forensics shard. + */ + +export { ExifPanel } from './ExifPanel'; +export { C2PAPanel } from './C2PAPanel'; +export { ELAViewer } from './ELAViewer'; +export { SunPositionPanel } from './SunPositionPanel'; +export { SimilarImagesPanel } from './SimilarImagesPanel'; +export { WarningsPanel } from './WarningsPanel'; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/index.ts b/packages/arkham-shard-shell/src/pages/media-forensics/index.ts new file mode 100644 index 0000000..cdb3572 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/index.ts @@ -0,0 +1,36 @@ +/** + * Media Forensics shard pages + * + * Export all page components for the Media Forensics shard. + */ + +export { MediaForensicsPage } from './MediaForensicsPage'; + +// Re-export types for convenience +export type { + MediaAnalysis, + ExifData, + C2PAData, + ELAResult, + SunPositionResult, + SimilarImage, + SimilarImagesResult, + ForensicFinding, + AnalysisStats, + AnalysisStatus, + VerificationStatus, + FindingSeverity, + C2PAValidationStatus, +} from './types'; + +// Re-export display helpers +export { + STATUS_LABELS, + STATUS_COLORS, + VERIFICATION_LABELS, + VERIFICATION_COLORS, + SEVERITY_LABELS, + SEVERITY_COLORS, + C2PA_VALIDATION_LABELS, + C2PA_VALIDATION_COLORS, +} from './types'; diff --git a/packages/arkham-shard-shell/src/pages/media-forensics/types.ts b/packages/arkham-shard-shell/src/pages/media-forensics/types.ts new file mode 100644 index 0000000..59428de --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/media-forensics/types.ts @@ -0,0 +1,490 @@ +/** + * Media Forensics Types + * + * Type definitions for the Media Forensics shard. + */ + +// ============================================ +// Status and Enum Types +// ============================================ + +export type AnalysisStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export type VerificationStatus = 'verified' | 'flagged' | 'unknown' | 'tampered'; + +export type FindingSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info'; + +export type C2PAValidationStatus = 'valid' | 'invalid' | 'expired' | 'revoked' | 'unknown'; + +// ============================================ +// EXIF Data Types +// ============================================ + +export interface ExifGPSData { + latitude: number | null; + longitude: number | null; + altitude: number | null; + altitude_ref: string | null; + gps_timestamp: string | null; + gps_date: string | null; + direction: number | null; + direction_ref: string | null; +} + +export interface ExifCameraData { + make: string | null; + model: string | null; + serial_number: string | null; + lens_make: string | null; + lens_model: string | null; + lens_serial: string | null; +} + +export interface ExifImageData { + width: number | null; + height: number | null; + orientation: number | null; + color_space: string | null; + bits_per_sample: number | null; + compression: string | null; +} + +export interface ExifCaptureSettings { + exposure_time: string | null; + f_number: number | null; + iso: number | null; + focal_length: number | null; + focal_length_35mm: number | null; + exposure_mode: string | null; + exposure_program: string | null; + metering_mode: string | null; + white_balance: string | null; + flash: string | null; +} + +export interface ExifTimestamps { + datetime_original: string | null; + datetime_digitized: string | null; + datetime_modified: string | null; + timezone_offset: string | null; +} + +export interface ExifSoftware { + software: string | null; + processing_software: string | null; + host_computer: string | null; + firmware: string | null; +} + +export interface ExifData { + camera: ExifCameraData; + image: ExifImageData; + gps: ExifGPSData; + capture: ExifCaptureSettings; + timestamps: ExifTimestamps; + software: ExifSoftware; + raw_data: Record; + warnings: string[]; +} + +// ============================================ +// C2PA (Content Credentials) Types +// ============================================ + +export interface C2PAAssertion { + label: string; + data: Record; + instance?: number; +} + +export interface C2PAAction { + action: string; + when: string | null; + software_agent: string | null; + parameters: Record; +} + +export interface C2PAIngredient { + title: string; + format: string; + document_id: string | null; + instance_id: string | null; + relationship: string; + thumbnail: string | null; +} + +export interface C2PASigner { + name: string; + organization: string | null; + issued_date: string | null; + expiry_date: string | null; + is_trusted: boolean; + trust_chain: string[]; + validation_status: C2PAValidationStatus; +} + +export interface C2PAManifest { + claim_generator: string; + title: string | null; + format: string | null; + instance_id: string | null; + assertions: C2PAAssertion[]; + actions: C2PAAction[]; + ingredients: C2PAIngredient[]; + signer: C2PASigner | null; + signature_date: string | null; +} + +export interface C2PAData { + has_manifest: boolean; + manifests: C2PAManifest[]; + active_manifest_index: number | null; + validation_status: C2PAValidationStatus; + validation_errors: string[]; + provenance_chain: string[]; +} + +// ============================================ +// ELA (Error Level Analysis) Types +// ============================================ + +export interface ELARegion { + x: number; + y: number; + width: number; + height: number; + avg_intensity: number; + max_intensity: number; + is_suspicious: boolean; + description: string; +} + +export interface ELAResult { + id: string; + doc_id: string; + analysis_id: string; + quality_level: number; + original_image_url: string | null; + ela_image_url: string | null; + ela_image_base64: string | null; + global_avg_intensity: number; + global_max_intensity: number; + suspicious_regions: ELARegion[]; + is_potentially_edited: boolean; + confidence: number; + generated_at: string; +} + +// ============================================ +// Sun Position Analysis Types +// ============================================ + +export interface SunPosition { + azimuth: number; + altitude: number; + calculated_time: string; + location_lat: number; + location_lon: number; +} + +export interface ShadowAnalysis { + detected_shadows: { + object_id: string; + shadow_direction: number; + shadow_length: number; + confidence: number; + }[]; + average_shadow_direction: number | null; + consistency_score: number; +} + +export interface SunPositionResult { + id: string; + doc_id: string; + analysis_id: string; + claimed_location: { lat: number; lon: number } | null; + claimed_time: string | null; + calculated_sun_position: SunPosition | null; + shadow_analysis: ShadowAnalysis | null; + is_consistent: boolean; + inconsistency_details: string[]; + confidence: number; + generated_at: string; +} + +// ============================================ +// Similar Images Types +// ============================================ + +export interface SimilarImage { + id: string; + doc_id: string; + filename: string; + similarity_score: number; + similarity_type: 'exact' | 'near_duplicate' | 'visually_similar' | 'content_similar'; + match_details: { + hash_distance?: number; + feature_similarity?: number; + matching_regions?: { x: number; y: number; width: number; height: number }[]; + source_url?: string; + source_domain?: string; + }; + thumbnail_url: string | null; + thumbnail_base64: string | null; + source: string | null; + found_at: string; +} + +export interface ReverseImageSearchUrl { + engine: string; + url: string; + icon: string; + description: string; + type: 'url_search' | 'upload_search'; + instructions?: string; +} + +export interface SimilarImagesResult { + id: string; + doc_id: string; + analysis_id: string; + search_type: 'internal' | 'external' | 'both'; + total_found: number; + similar_images: SimilarImage[]; + exact_matches: number; + near_duplicates: number; + visually_similar: number; + generated_at: string; + search_urls?: ReverseImageSearchUrl[]; // URLs for manual reverse image search +} + +// ============================================ +// Forensic Finding Types +// ============================================ + +export interface ForensicFinding { + id: string; + category: 'exif' | 'c2pa' | 'ela' | 'sun_position' | 'similar_images' | 'general'; + severity: FindingSeverity; + title: string; + description: string; + evidence: Record; + recommendation: string | null; + confidence: number; + auto_detected: boolean; + detected_at: string; +} + +// ============================================ +// Media Analysis Types +// ============================================ + +export interface PerceptualHashes { + phash: string | null; + dhash: string | null; + ahash: string | null; +} + +export interface MediaAnalysis { + id: string; + doc_id: string; + filename: string; + file_type: string; + file_size: number; + file_hash_md5: string | null; + file_hash_sha256: string | null; + status: AnalysisStatus; + verification_status: VerificationStatus; + integrity_status: string; + + // Analysis components + exif_data: ExifData | null; + c2pa_data: C2PAData | null; + ela_result: ELAResult | null; + sun_position_result: SunPositionResult | null; + similar_images_result: SimilarImagesResult | null; + perceptual_hashes: PerceptualHashes | null; + + // Findings + findings: ForensicFinding[]; + findings_count: number; + critical_findings: number; + high_findings: number; + + // Metadata + analyzed_at: string; + updated_at: string; + analyzed_by: string; + notes: string | null; +} + +// ============================================ +// Statistics Types +// ============================================ + +export interface AnalysisStats { + total_analyses: number; + by_status: Record; + by_verification: Record; + with_c2pa: number; + with_exif: number; + with_gps: number; + with_findings: number; + critical_findings_total: number; + high_findings_total: number; + ela_analyses: number; + sun_position_analyses: number; + similar_images_searches: number; + avg_findings_per_analysis: number; +} + +// ============================================ +// API Request/Response Types +// ============================================ + +export interface AnalyzeDocumentRequest { + doc_id: string; + run_ela?: boolean; + run_sun_position?: boolean; + run_similar_search?: boolean; + similar_search_type?: 'internal' | 'external' | 'both'; +} + +export interface AnalyzeDocumentResponse { + analysis: MediaAnalysis; + job_id?: string; +} + +export interface AnalysisListResponse { + total: number; + items: MediaAnalysis[]; + offset: number; + limit: number; + has_more: boolean; +} + +export interface GenerateELARequest { + analysis_id: string; + quality_level?: number; +} + +export interface GenerateELAResponse { + result: ELAResult; +} + +export interface SunPositionRequest { + analysis_id: string; + override_location?: { lat: number; lon: number }; + override_time?: string; +} + +export interface SunPositionResponse { + result: SunPositionResult; +} + +export interface FindSimilarRequest { + analysis_id: string; + search_type?: 'internal' | 'external' | 'both'; + limit?: number; +} + +export interface FindSimilarResponse { + result: SimilarImagesResult; +} + +export interface StatsResponse { + stats: AnalysisStats; +} + +export interface C2PASupportResponse { + supported: boolean; + version: string | null; + message: string; +} + +// ============================================ +// Display Helpers +// ============================================ + +export const STATUS_LABELS: Record = { + pending: 'Pending', + processing: 'Processing', + completed: 'Completed', + failed: 'Failed', +}; + +export const STATUS_COLORS: Record = { + pending: 'blue', + processing: 'yellow', + completed: 'green', + failed: 'red', +}; + +export const VERIFICATION_LABELS: Record = { + verified: 'Verified', + flagged: 'Flagged', + unknown: 'Unknown', + tampered: 'Tampered', +}; + +export const VERIFICATION_COLORS: Record = { + verified: 'var(--arkham-success, #22c55e)', + flagged: 'var(--arkham-warning, #eab308)', + unknown: 'var(--arkham-text-muted, #6b7280)', + tampered: 'var(--arkham-error, #ef4444)', +}; + +export const SEVERITY_LABELS: Record = { + critical: 'Critical', + high: 'High', + medium: 'Medium', + low: 'Low', + info: 'Info', +}; + +export const SEVERITY_COLORS: Record = { + critical: 'var(--arkham-error, #ef4444)', + high: 'var(--arkham-warning, #f97316)', + medium: 'var(--arkham-warning, #eab308)', + low: 'var(--arkham-info, #3b82f6)', + info: 'var(--arkham-text-muted, #6b7280)', +}; + +export const C2PA_VALIDATION_LABELS: Record = { + valid: 'Valid', + invalid: 'Invalid', + expired: 'Expired', + revoked: 'Revoked', + unknown: 'Unknown', +}; + +export const C2PA_VALIDATION_COLORS: Record = { + valid: 'var(--arkham-success, #22c55e)', + invalid: 'var(--arkham-error, #ef4444)', + expired: 'var(--arkham-warning, #f97316)', + revoked: 'var(--arkham-error, #dc2626)', + unknown: 'var(--arkham-text-muted, #6b7280)', +}; + +// Filter options +export const STATUS_OPTIONS: { value: AnalysisStatus; label: string }[] = [ + { value: 'pending', label: 'Pending' }, + { value: 'processing', label: 'Processing' }, + { value: 'completed', label: 'Completed' }, + { value: 'failed', label: 'Failed' }, +]; + +export const VERIFICATION_OPTIONS: { value: VerificationStatus; label: string }[] = [ + { value: 'verified', label: 'Verified' }, + { value: 'flagged', label: 'Flagged' }, + { value: 'unknown', label: 'Unknown' }, + { value: 'tampered', label: 'Tampered' }, +]; + +export const SEVERITY_OPTIONS: { value: FindingSeverity; label: string }[] = [ + { value: 'critical', label: 'Critical' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, + { value: 'info', label: 'Info' }, +];