Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 27 additions & 31 deletions backend/routers/field_officer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Response
from fastapi.concurrency import run_in_threadpool
from sqlalchemy.orm import Session
from sqlalchemy import func, case
from typing import List, Optional
Expand Down Expand Up @@ -34,6 +35,7 @@
)
from backend.cache import visit_last_hash_cache, visit_stats_cache
from backend.schemas import BlockchainVerificationResponse
from backend.utils import process_uploaded_image, save_processed_image

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -301,55 +303,49 @@ async def upload_visit_images(
)

image_paths = []

for idx, image in enumerate(images):
# Validate content_type is present
if not image.content_type:
raise HTTPException(status_code=400, detail="File must have a content type")

# Validate file type
if not image.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail=f"File must be an image, got {image.content_type}")

# Validate filename is present
if not image.filename:
raise HTTPException(status_code=400, detail="File must have a filename")

# Validate extension
extension = image.filename.split('.')[-1].lower() if '.' in image.filename else ''
# Validate extension (Strict check to maintain original behavior)
if not image.filename or '.' not in image.filename:
raise HTTPException(status_code=400, detail="Invalid filename")

extension = image.filename.split('.')[-1].lower()
if extension not in ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File extension '{extension}' not allowed. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
detail=f"File extension '{extension}' not allowed."
)

# Read and validate file size
content = await image.read()
if len(content) > MAX_UPLOAD_SIZE:

# Optimization: Unified validation, resizing, and EXIF stripping in one pass.
# This avoids multiple redundant decode/encode cycles and ensures optimal storage.
_, image_bytes = await process_uploaded_image(image)
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The 10MB limit is now enforced only after processing, so raw files up to 20MB may pass initial validation and violate this endpoint’s original upload-size contract.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At backend/routers/field_officer.py, line 321:

<comment>The 10MB limit is now enforced only after processing, so raw files up to 20MB may pass initial validation and violate this endpoint’s original upload-size contract.</comment>

<file context>
@@ -301,55 +303,49 @@ async def upload_visit_images(
+
+            # Optimization: Unified validation, resizing, and EXIF stripping in one pass.
+            # This avoids multiple redundant decode/encode cycles and ensures optimal storage.
+            _, image_bytes = await process_uploaded_image(image)
+
+            # Enforce stricter 10MB limit for this specific endpoint (original constraint)
</file context>
Suggested change
_, image_bytes = await process_uploaded_image(image)
image.file.seek(0, 2)
raw_size = image.file.tell()
image.file.seek(0)
if raw_size > MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File {image.filename} exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB"
)
_, image_bytes = await process_uploaded_image(image)
Fix with Cubic


Comment on lines +319 to +322
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process_uploaded_image() re-encodes images via PIL. For animated formats that are currently allowed here (notably GIF, and potentially animated WebP), this approach typically drops all but the first frame. If animated uploads are meant to be supported, the processing pipeline needs to preserve frames; otherwise consider removing gif (and/or animated WebP) from ALLOWED_IMAGE_EXTENSIONS for this endpoint or explicitly rejecting animated images during processing.

Copilot uses AI. Check for mistakes.
# Enforce stricter 10MB limit for this specific endpoint (original constraint)
if len(image_bytes) > MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File {image.filename} exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB"
status_code=413,
detail=f"Processed image exceeds {MAX_UPLOAD_SIZE / (1024*1024):.0f}MB limit"
)
Comment on lines +319 to 328
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint previously enforced a 10MB limit on the uploaded file bytes, but now the only hard limit before processing is backend.utils.MAX_FILE_SIZE (20MB). As a result, a 15–20MB upload can pass process_uploaded_image() and then slip through if the resized/encoded output is <10MB, which contradicts the documented/original 10MB per-image constraint. Add a pre-processing check against MAX_UPLOAD_SIZE using the raw upload size (e.g., image.file.seek/tell before calling process_uploaded_image), or extend process_uploaded_image to accept a max-size override for this endpoint.

Copilot uses AI. Check for mistakes.

# Generate secure filename
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
safe_filename = f"visit_{visit_id}_{timestamp}_{idx}.{extension}"
file_path = os.path.join(VISIT_IMAGES_DIR, safe_filename)

# Save file
with open(file_path, 'wb') as f:
f.write(content)


# Performance Boost: Offload blocking File I/O to threadpool
await run_in_threadpool(save_processed_image, image_bytes, file_path)

# Store relative path
relative_path = os.path.join("data", "visit_images", safe_filename)
image_paths.append(relative_path)

# Update visit with image paths
existing_images.extend(image_paths)
visit.visit_images = existing_images
visit.updated_at = datetime.now(timezone.utc)

db.commit()

# Performance Boost: Offload blocking DB commit to threadpool
await run_in_threadpool(db.commit)
Comment on lines +335 to +348
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify SQLAlchemy session configuration for thread safety.

# Check how get_db is implemented
rg -nA 10 'def get_db' backend/database.py backend/ 2>/dev/null || rg -nA 10 'def get_db' --type=py

# Check for scoped_session usage
rg -n 'scoped_session' --type=py

Repository: RohanExploit/VishwaGuru

Length of output: 538


SQLAlchemy session is not thread-safe; db.commit() cannot safely execute on threadpool.

The get_db() dependency creates a regular SessionLocal() instance (not a scoped_session), which is not thread-safe. When await run_in_threadpool(db.commit) executes the commit on a worker thread while the session accumulated state on the main async thread, it violates SQLAlchemy's threading model and risks race conditions and connection pool corruption.

Instead, either:

  1. Remove the threadpool offload for db.commit() (commit is fast; I/O is the bottleneck), or
  2. Refactor session management to properly scope sessions per thread (e.g., use scoped_session with a thread-aware scopefunc), or
  3. Perform all DB operations within the async context before returning.
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routers/field_officer.py` around lines 335 - 348, The code currently
calls await run_in_threadpool(db.commit) which is unsafe because the Session
returned by get_db()/SessionLocal is not thread-safe; remove the threadpool
offload and call db.commit() directly on the same execution context (i.e.,
replace await run_in_threadpool(db.commit) with db.commit()), ensuring
visit.updated_at and visit.visit_images are set before that call; alternatively,
if you must offload commits, refactor session management to use a thread-scoped
session (scoped_session with a proper scopefunc) and ensure commits happen on
the same thread-bound session instance instead of sharing the existing db object
across threads.

Comment on lines +347 to +348
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await run_in_threadpool(db.commit) runs the SQLAlchemy Session commit in a different thread than the one where the session was used to load/mutate visit. SQLAlchemy sessions are not thread-safe, and using the same session across threads can lead to intermittent failures or connection state corruption. Prefer keeping all DB work on one thread (e.g., make the endpoint sync def so FastAPI runs it in its worker thread), or move all session usage (query + updates + commit) into a single run_in_threadpool callable, or switch to an async DB stack (AsyncSession/async engine) instead of offloading only commit.

Suggested change
# Performance Boost: Offload blocking DB commit to threadpool
await run_in_threadpool(db.commit)
# Keep all SQLAlchemy Session usage on the same thread.
db.commit()

Copilot uses AI. Check for mistakes.

logger.info(f"Uploaded {len(images)} images for visit {visit_id}")

Expand Down
Loading