Skip to content
Open
Show file tree
Hide file tree
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
10 changes: 5 additions & 5 deletions .firebase/hosting.ZnJvbnRlbmQ.cache
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
index.html,1769607006930,13a2777ea38d55eae096e7be4fe8ef948e4ed9fb7ea6b2a2cd9902418edf3038
404.html,1766671073811,05cbc6f94d7a69ce2e29646eab13be2c884e61ba93e3094df5028866876d18b3
static/js/state-manager.js,1768928200351,567bcef4c1959bec4dce544d241e5e6f2e419deab1b6b4a68c7ef74043ab44a0
static/js/script.js,1768928200351,07e174130e2a2415c57d17b4048ac70d866c63f08990a40dbaf9bc40b821fe19
static/js/photo-processor.js,1768928200350,95be2df099eb9793da9778ee8cd87283627bf80e52133719092c423e0757d355
static/js/config.js,1767980752113,78a50979181fc33d8cd9d3632de0ff48baa9472ca57ddd99e309b45e3d9561c7
static/js/batch-operations.js,1765904443892,150c9a4eaf48f392a41a00ac69d3c7e36730146565c4bed007998132f5294170
static/js/analytics-dashboard.js,1768583277770,ccfac25ed7a067c948ff0c272c3d1c3c87a4125985dea2857f154a211af3ea1b
static/js/components/atoms/Button.js,1766671073815,857f21eae50e3bf582dd64a5d928e638dc8b7901b50956f0f7916220d47c22e8
static/images/milind.jpeg,1768928200350,3ab0c056b70237858b14628beb76f4030f3ab8b61c77a5407ff4ce74c0abc3dd
static/images/miapatrikios.jpeg,1755189202858,33154529bc40f43447874a8dbadab1146a7579a8acef292530e0af48b5f32b4c
static/images/jonahfreedman.jpeg,1755189265752,379959114a796a7c9fc80be8ed4a87f1901b28a02999814fb8c5ba409e64481a
static/images/bridgerjones.jpeg,1768928200347,e796c96c90df3149a67c6ee2e92dcb733e38b00e0865ed6ee7b55130edcb13fa
static/images/TS_Logo.png,1768934071539,4e1da35a1b5fc8ba15bc71cba56e09dc86bba260df8686d211e2873cc096846a
static/css/photo-viewer.css,1767673385113,eb3a5ee1584eced2b5166212162ee397015d761cda66f81d40df25cafb055e7a
static/css/global.css,1768928200340,edf2b29c9a411c43f1fccba9a1552dd46cb17a0faf2b574753e30dd1751592b4
static/css/core.css,1767673385113,b7b687a0b9841fdb45b1573751f17447080915933142be87a9401d7707eabfb1
Expand All @@ -21,5 +20,6 @@ components/tier-display.js,1767980752111,4844e8c8a28b8d077e4a3ae0630db3a51331f50
components/pricing-cards-manager.js,1768928200339,7ec111302220cebdb92d1f4210acd35027a463de25636b7c0794cdafb129dbe8
components/pricing-card.js,1768928200339,7d959c824881f01d0a55b6b5866359d326431f2a96a07743d42b2b6507a65f62
components/payment-form.js,1768928200338,5b40dd9510f4869f2f49fd8673e14e4e47dc96160903ff01abb9bdd76cf6ae27
static/images/TS_Logo.png,1768934071539,4e1da35a1b5fc8ba15bc71cba56e09dc86bba260df8686d211e2873cc096846a
index.html,1768934071631,b7b2388753d9b176822fa515f799072956540975de051fc8048e2438c7e27b4e
static/js/state-manager.js,1769628397905,653a2de3641186a2613587318b58dbadb20ab63299dd9c17883bb7e7b743ac1a
static/js/analytics-dashboard.js,1769628362925,124395bb129edb4cad576f27f2db7f50616d52d62de0dc394d9902b8edb88015
static/js/photo-processor.js,1769628409934,8d5e2b6274a680a41bfbcfd46478773613d9085bae696feea4154112d6179a81
126 changes: 55 additions & 71 deletions backend/app/api/direct_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
from typing import List

from fastapi import APIRouter, Depends, HTTPException, status
from google.cloud import storage
from sqlalchemy.orm import Session

from app.api.auth import get_current_user
from app.core.config import settings
from app.core.gcs import get_gcs_bucket
from app.core.security_config import ALLOWED_EXTENSIONS
from app.models.processing import PhotoDB, ProcessingStatus
from app.models.schemas import (
SignedUploadRequest,
SignedUploadResponse,
SignedUploadResponse,
SignedUrlInfo,
UploadCompletionRequest,
UploadCompletionResponse,
Expand All @@ -31,29 +32,11 @@

router = APIRouter()
logger = logging.getLogger(__name__)

# GCS Configuration
BUCKET_NAME = settings.bucket_name
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".bmp"}
MAX_FILE_SIZE = settings.get_max_file_size_bytes()

# Initialize GCS Client
try:
if BUCKET_NAME:
storage_client = storage.Client()
bucket = storage_client.bucket(BUCKET_NAME)
logger.info(f"✅ Direct upload: Connected to GCS bucket: {BUCKET_NAME}")
else:
logger.error("❌ No bucket configured for direct uploads")
bucket = None
except Exception as e:
logger.error(f"❌ Could not connect to GCS for direct uploads: {e}")
bucket = None


# Utility functions


def get_file_extension(filename: str) -> str:
return os.path.splitext(filename.lower())[1]

Expand All @@ -72,34 +55,35 @@ async def get_signed_upload_urls(
Step 1: Generate signed URLs for direct browser-to-GCS uploads.
This replaces the slow server proxy method with fast direct uploads.
"""

try:
logger.info(f"🔗 User {current_user.id} requesting signed URLs for {len(request.files)} files")

logger.info(f"User {current_user.id} requesting signed URLs for {len(request.files)} files")

bucket = get_gcs_bucket()
if not bucket:
logger.error("GCS bucket not configured")
logger.error("GCS bucket not configured")
raise HTTPException(
status_code=500,
status_code=500,
detail="Google Cloud Storage not configured"
)

if not request.files:
raise HTTPException(status_code=400, detail="No files specified")

# Check quota before generating URLs
photo_count = len(request.files)
can_upload, quota_message = usage_tracker.check_user_quota(
db, current_user.id, ActionType.UPLOAD, photo_count
)

if not can_upload:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
status_code=status.HTTP_403_FORBIDDEN,
detail=quota_message
)

signed_urls = []

for file_info in request.files:
# Validate file
if not is_allowed_file(file_info.filename):
Expand All @@ -108,37 +92,36 @@ async def get_signed_upload_urls(
detail=f"File {file_info.filename} has invalid extension. "
f"Allowed: {', '.join(ALLOWED_EXTENSIONS)}",
)

if file_info.size > MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"File {file_info.filename} exceeds maximum size "
f"of {settings.max_file_size_mb}MB",
)

# Generate unique photo ID and GCS filename
photo_id = str(uuid.uuid4())
file_extension = get_file_extension(file_info.filename)
gcs_filename = f"{photo_id}{file_extension}"
blob_path = f"{current_user.id}/{gcs_filename}"

try:
# Create blob reference
blob = bucket.blob(blob_path)

# Generate signed URL for PUT operation (valid for 15 minutes)
# Use service_account_email=None for Cloud Run ADC compatibility
from google.auth import default
from google.auth.transport import requests as google_requests

# Get the default credentials and service account email
credentials, project_id = default()
auth_request = google_requests.Request()
credentials.refresh(auth_request)

# For Cloud Run, use the default service account
service_account_email = credentials.service_account_email if hasattr(credentials, 'service_account_email') else None

signed_url = blob.generate_signed_url(
version="v4",
expiration=timedelta(minutes=15),
Expand All @@ -147,7 +130,7 @@ async def get_signed_upload_urls(
service_account_email=service_account_email,
access_token=credentials.token if hasattr(credentials, 'token') else None,
)

signed_urls.append(SignedUrlInfo(
photo_id=photo_id,
filename=file_info.filename,
Expand All @@ -158,28 +141,28 @@ async def get_signed_upload_urls(
content_type=file_info.content_type,
size=file_info.size
))
logger.info(f"Generated signed URL for {file_info.filename} -> {photo_id}")

logger.info(f"Generated signed URL for {file_info.filename} -> {photo_id}")

except Exception as e:
logger.error(f"Failed to generate signed URL for {file_info.filename}: {e}")
logger.error(f"Failed to generate signed URL for {file_info.filename}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to generate signed URL for {file_info.filename}"
)

return SignedUploadResponse(
signed_urls=signed_urls,
expires_in_minutes=15,
bucket_name=BUCKET_NAME,
bucket_name=settings.bucket_name,
message=f"Generated {len(signed_urls)} signed URLs for direct upload"
)

except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
logger.error(f"Unexpected error in signed URL generation: {e}")
logger.error(f"Unexpected error in signed URL generation: {e}")
raise HTTPException(
status_code=500,
detail="Internal server error during signed URL generation"
Expand All @@ -196,67 +179,68 @@ async def complete_upload(
Step 3: Record successful direct uploads in the database.
Called by frontend after files have been uploaded directly to GCS.
"""

if not request.completed_uploads:
raise HTTPException(status_code=400, detail="No completed uploads provided")


bucket = get_gcs_bucket()
successful_photos = []
failed_photos = []
total_file_size_mb = 0.0

for upload in request.completed_uploads:
try:
# Optional: Verify the file actually exists in GCS
blob_path = f"{current_user.id}/{upload.gcs_filename}"

if bucket:
blob = bucket.blob(blob_path)
if not blob.exists():
logger.warning(f"⚠️ File not found in GCS: {blob_path}")
logger.warning(f"File not found in GCS: {blob_path}")
failed_photos.append({
"photo_id": upload.photo_id,
"error": "File not found in Google Cloud Storage"
})
continue

# Create database record using our optimized PhotoDB model
photo_db = PhotoDB(
photo_id=upload.photo_id,
user_id=current_user.id,
original_filename=upload.original_filename,
file_path=blob_path,
file_size_bytes=upload.size,
file_extension=upload.file_extension, # ✅ This fixes the O(N) -> O(1) issue!
file_extension=upload.file_extension,
processing_status=ProcessingStatus.PENDING
)

db.add(photo_db)

# Calculate stats
file_size_mb = upload.size / (1024 * 1024)
total_file_size_mb += file_size_mb

successful_photos.append(upload.photo_id)
logger.info(f"Recorded direct upload: {upload.photo_id}")

logger.info(f"Recorded direct upload: {upload.photo_id}")

except Exception as e:
logger.error(f"Failed to record upload for {upload.photo_id}: {e}")
logger.error(f"Failed to record upload for {upload.photo_id}: {e}")
failed_photos.append({
"photo_id": upload.photo_id,
"error": str(e)
})

# Commit successful uploads
try:
db.commit()

# Update usage tracking
if successful_photos:
usage_tracker.use_quota(
db, current_user.id, ActionType.UPLOAD, len(successful_photos)
)

usage_tracker.log_action(
db=db,
user_id=current_user.id,
Expand All @@ -265,18 +249,18 @@ async def complete_upload(
file_size_mb=total_file_size_mb,
success=True,
)

current_user.increment_photos_uploaded(len(successful_photos))
db.commit()

except Exception as e:
logger.error(f"Database commit failed: {e}")
logger.error(f"Database commit failed: {e}")
db.rollback()
raise HTTPException(status_code=500, detail="Failed to record uploads")

# Get updated quota info
updated_quota = usage_tracker.get_or_create_user_quota(db, current_user.id)

return UploadCompletionResponse(
successful_uploads=len(successful_photos),
failed_uploads=len(failed_photos),
Expand All @@ -294,4 +278,4 @@ async def complete_upload(
},
message=f"Successfully recorded {len(successful_photos)} uploads"
+ (f", {len(failed_photos)} failed" if failed_photos else "")
)
)
Loading