```html

Building a Serverless Photo Upload Pipeline for QuickDumpNow's Mobile Capture Workflow

Over the past development session, we implemented a three-part feature set for QuickDumpNow's customer-facing dashboard: an on-the-go photo capture button, a Stripe-integrated booking flow, and an iOS GPS shortcut integration. This post focuses on the infrastructure and architectural decisions behind the photo upload pipeline—the foundation enabling customers to attach photos directly from their mobile devices during job submissions.

What We Built

The core requirement was simple but operationally complex: allow customers to upload photos from mobile browsers without exposing our primary storage bucket to public internet traffic. We needed:

  • A serverless endpoint generating time-limited, signature-authenticated upload URLs
  • A dashboard UI with capture buttons wired into the job submission drawer
  • A presigned GET mechanism for displaying uploaded photos back to customers
  • Integration with existing S3 bucket policies and Lambda execution roles

The architecture reused an existing presigned-URL pattern already in production for receipt uploads, extending it to handle arbitrary photo attachments with proper access control.

Infrastructure Setup and Bucket Strategy

All uploads target the qdn-uploads S3 bucket, which is private by design—no public bucket access policy. Instead, access is mediated entirely through AWS Lambda functions running under the qdn-lambda-role IAM role.

The role has these key inline policies:

  • s3:GetObject, s3:PutObject, s3:ListBucket on arn:aws:s3:::qdn-uploads and arn:aws:s3:::qdn-uploads/*
  • s3:GetObject on the dashboard CloudFront bucket dashboard.quickdumpnow.com (for serving the dashboard HTML itself)
  • CloudFront invalidation permissions for cache busting after Lambda code updates

This role-based access pattern means:

  • The bucket never needs a public read/write policy
  • Customers receive temporary, one-time-use upload credentials via Lambda presigned URLs
  • All uploads are authenticated to a specific AWS principal (our Lambda execution role)
  • CloudFront distribution d[DIST_ID] remains the sole public entry point for dashboard HTML

Lambda Endpoint Architecture

We added two new routes to the API Gateway fronting our Lambda function:

POST /upload-url     → generates presigned S3 PUT URLs
GET  /list-photos    → returns presigned GET URLs for previously uploaded objects

The /upload-url endpoint lives in /Users/cb/Documents/repos/sites/dashboard.quickdumpnow.com/lambda/lambda_function.py:

def generate_upload_url(job_id, filename):
    """Generate a presigned URL for uploading to qdn-uploads."""
    s3_client = boto3.client('s3')
    key = f"jobs/{job_id}/{filename}"
    
    presigned_url = s3_client.generate_presigned_url(
        'put_object',
        Params={'Bucket': 'qdn-uploads', 'Key': key},
        ExpiresIn=3600  # 1-hour window
    )
    return presigned_url

The ExpiresIn=3600 setting gives customers a 1-hour window to complete the upload—enough time for mobile network interruptions but short enough to limit credential reuse if URLs leak.

The /list-photos endpoint mirrors this pattern but generates GET URLs instead:

def list_job_photos(job_id):
    """Return presigned GETs for all objects under jobs/{job_id}/."""
    s3_client = boto3.client('s3')
    s3 = boto3.resource('s3')
    bucket = s3.Bucket('qdn-uploads')
    
    photos = []
    for obj in bucket.objects.filter(Prefix=f"jobs/{job_id}/"):
        presigned_get = s3_client.generate_presigned_url(
            'get_object',
            Params={'Bucket': 'qdn-uploads', 'Key': obj.key},
            ExpiresIn=86400  # 24 hours for viewing
        )
        photos.append({
            'key': obj.key,
            'url': presigned_get,
            'size': obj.size
        })
    return photos

GET URLs expire after 24 hours, allowing the dashboard to refresh the page and regenerate fresh credentials without customer friction.

Frontend Integration

The dashboard HTML (/Users/cb/Documents/repos/sites/dashboard.quickdumpnow.com/index.html) was updated with capture buttons in the job drawer:

<button id="capture-photo" class="drawer-button">📸 Add Photo</button>

JavaScript calls POST /upload-url with the current job_id, receives a presigned URL, then uses the browser's Fetch API to upload directly to S3:

async function captureAndUpload(jobId) {
    // 1. Get presigned upload URL from Lambda
    const urlResp = await fetch('/upload-url', {
        method: 'POST',
        body: JSON.stringify({ job_id: jobId, filename: generateFilename() })
    });
    const { presigned_url } = await urlResp.json();
    
    // 2. User selects photo via input[type=file]
    const file = /* selected file */;
    
    // 3. Upload directly to S3 using presigned URL
    const uploadResp = await fetch(presigned_url, {
        method: 'PUT',
        body: file,
        headers: { 'Content-Type': file.type }
    });
    
    if (uploadResp.ok) {
        refreshPhotoThumbnails(jobId);
    }
}

This direct-to-S3 upload offloads bandwidth from our Lambda and removes the need to proxy file bytes through AWS Lambda—a critical optimization for large photos on metered mobile connections.

Deployment and Cache Invalidation

The updated Lambda code was deployed via the AWS CLI:

aws lambda update-function-code \
  --function-name qdn-upload-presign \
  --zip-file fileb:///tmp/lambda_deployment.zip \
  --region us-west-2

The dashboard HTML staging files were copied to the CloudFront bucket and invalidated:

aws s3 cp /Users/cb/Documents/repos/sites/dashboard.quickdumpnow.com/index.html \
  s3://dashboard.quickdumpnow.com/index.html

aws cloudfront create-invalidation \
  --distribution-id d[DIST_ID] \
  --paths "/*"

This flush ensures all edge locations serve the new button UI immediately.

Key Architectural Decisions

  • Presigned URLs over proxy uploads: Direct-to-S3 uploads reduce Lambda memory and duration costs while improving mobile UX.
  • Job-keyed folder