```html

Building the Sailor Board: Multi-Event Photo Gallery with Real-Time Instagram Integration

Over the past development session, we completed a major feature build for Queen of San Diego's guest experience: a unified photo gallery system ("Sailor Board") that aggregates guest uploads across charter events with real-time Instagram hashtag search. This post details the architecture, infrastructure decisions, and implementation patterns used to ship this feature.

What Was Built

The "Sailor Board" consists of three core components:

  • Per-event guest pages at /g/{event_id}/ that allow charter guests to upload photos/videos with optional event code verification for instant publication or moderation queue assignment
  • Photo schema and Lambda-backed API supporting upload presigning, EXIF/metadata extraction, thumbnail generation, and moderation workflows
  • Instagram integration layer that queries hashtags (#jada, #queenofsandiego) filtered to event date and renders results alongside guest uploads

The system handles up to 24 simultaneous uploads per guest, enforces file type validation (JPEG, PNG, WebP, HEIC, HEIF for images; MP4, MOV, AVI, WebM for video), and runs all presigning and metadata operations through AWS Lambda to avoid exposing S3 credentials client-side.

Infrastructure: S3, Lambda, and CloudFront

Storage Layer: Guest photos live in a dedicated S3 bucket (queenofsandiego-guest-photos) organized by event date and guest identifier. The bucket is configured with:

  • CORS policy allowing queenofsandiego.com origins (including staging and production CloudFront aliases)
  • Server-side encryption (AES-256)
  • Presigned URL generation through Lambda (15-minute expiry per upload request)
  • Lifecycle policies archiving photos older than 6 months to Glacier

A secondary bucket (queenofsandiego-guest-photos-thumbs) stores pre-generated thumbnail variants (480px, 1200px widths) to avoid client-side image resizing and reduce bandwidth.

Compute Layer: The Lambda function at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py serves three HTTP endpoints:

  • POST /api/upload/presign — validates event code (if provided), generates S3 presigned URL, returns metadata schema
  • POST /api/upload/confirm — receives upload metadata (EXIF, file size, dimensions), persists to DynamoDB, triggers thumbnail generation via Step Functions
  • GET /api/g/{event_id}/photos — fetches guest uploads from DynamoDB and Instagram API results, applies date filtering, returns merged gallery JSON

The function runs Python 3.11 with the following key dependencies:

  • boto3 for S3 presigning and DynamoDB writes
  • Pillow (PIL) for thumbnail generation and EXIF extraction
  • Instagram Graph API client (custom wrapper in lambda_function.py, lines 1800–1900)
  • py_vapid for push notification support (added during this session)

Function size: ~8.2 MB (compressed deployment package). Lambda is deployed to the us-west-2 region with 512 MB memory and 30-second timeout. CloudWatch Logs retention is set to 30 days.

CDN and DNS: Guest pages are deployed to production S3 bucket (queenofsandiego.com-web) and served through CloudFront distribution E3XXXXXX with queenofsandiego.com CNAME. A staging distribution (E2XXXXXX) uses alias staging.queenofsandiego.com for pre-release testing. Route53 health checks monitor the production origin.

Photo Upload Flow: Why Presigning Over Direct Upload

We chose S3 presigned URLs instead of direct form POST because:

  • Security: Credentials never leave the backend; browsers can't infer bucket structure or object paths
  • Metadata capture: We intercept uploads at confirmation time to extract EXIF (camera model, GPS, timestamp) before DynamoDB persist, enabling rich filtering and sorting
  • Event gating: Event codes are validated server-side before presigning; guests without codes go to moderation queue regardless of upload success
  • Rate limiting: DynamoDB write capacity throttles aggressive re-uploaders; 24-file limit is enforced client-side but also in the `handleFiles()` function (line 515 of guest page HTML)

The presign endpoint returns this schema:

{
  "uploadId": "uuid",
  "url": "https://s3.us-west-2.amazonaws.com/queenofsandiego-guest-photos/...",
  "fields": {
    "acl": "public-read",
    "policy": "...",
    "signature": "...",
    "key": "2026-05-24/keely-afternoon/uuid.jpg"
  },
  "expiresAt": 1234567890
}

Guests then POST to S3 directly; on success, the browser calls /api/upload/confirm with file metadata, triggering thumbnail generation via a Step Functions state machine.

Instagram Integration: Hashtag Search and Date Filtering

The Instagram layer lives in the Lambda function's fetch_instagram_posts(event_id, event_date) method (lines 1850–1920). It:

  • Queries the Instagram Graph API for posts tagged #jada and #queenofsandiego within a 24-hour window (event date ± 12 hours)
  • Filters for public accounts (not private/archived)
  • Caches results in DynamoDB under event_id key for 6 hours to avoid API quota exhaustion (Instagram allows ~200 requests/hour per app)
  • Returns an array of objects: { username, caption, media_url, timestamp, permalink }

The client-side gallery (guest page HTML, lines 452–459) receives this as d.instagram and renders it in #ig-grid alongside guest uploads, marked with an Instagram logo badge.

Why this approach? Server-side filtering avoids exposing the Instagram token to the browser and lets us enforce consistent date ranges per event. Client-side caching (60 seconds in localStorage) prevents redundant API calls on page reload.

Event Code Verification and Moderation Queue

The event code system gates instant publication:

  • Guests with a valid code (provided by Captain at booking) upload directly to the live gallery
  • Guests without a code (or invalid code) are placed in a moderation queue stored in DynamoDB table queenofsandiego-guest-uploads-pending