```html

Building the Sailor Board: Real-Time Photo Gallery & Instagram Integration for Event Guest Pages

What Was Done

Over this development session, we completed the architecture and deployment pipeline for Queen of San Diego's per-event guest photo galleries—colloquially called the "Sailor Board." The feature allows charter guests to upload photos/videos immediately after sailing, with real-time moderation, event-code-gated spam prevention, and live Instagram hashtag integration. This post covers the end-to-end technical implementation: Lambda handlers, S3 + CloudFront infrastructure, CORS configuration, photo thumbnail generation, and the client-side upload widget.

Architecture Overview

The Sailor Board is built on three architectural pillars:

  • Guest Page Template: Per-event HTML page at /g/{event_id}/index.html deployed to S3 + CloudFront, with embedded JavaScript upload widget
  • Lambda API Gateway: Serverless handlers in shipcaptaincrew/lambda_function.py managing presigned S3 uploads, photo metadata, moderation, and Instagram feed aggregation
  • S3 Photo Buckets: Separate staging and production buckets for uploads, with DynamoDB metadata table for gallery state

Key Technical Components

Photo Upload Handler

The upload flow begins with a presigned S3 URL request. The Lambda function handle_presigned_upload_request() in tools/shipcaptaincrew/lambda_function.py (circa line 1800) validates:

  • Event ID exists and is post-sail (checked against FLIP_UTC timestamp)
  • Event code matches (if provided), or photo goes to review queue
  • File type is whitelisted: JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM

The handler returns a presigned POST policy valid for 15 minutes, allowing direct browser-to-S3 uploads without proxying through Lambda. This reduces latency and Lambda invocation costs.

POST https://s3.{region}.amazonaws.com/{bucket}/{event_id}/{filename}
Content-Type: multipart/form-data
X-Amz-Policy: [base64-encoded policy]
X-Amz-Signature: [HMAC-SHA256]

The guest page JavaScript (booking-widget.js, lines 514–520) then batches up to 24 files and calls the presigned endpoint directly:

Array.from(files).slice(0, 24).forEach(uploadFile);
// uploadFile constructs FormData with policy + signature
// and POSTs to presigned S3 endpoint

Photo Metadata & Moderation

After S3 upload completes, an S3 event trigger (configured in the bucket's notification settings) invokes a Lambda function to:

  • Write photo metadata to DynamoDB table qos-photos with attributes: event_id, photo_id, upload_timestamp, guest_code_provided (boolean), moderation_status (PENDING/APPROVED/REJECTED)
  • If event code was provided, auto-approve; otherwise mark as PENDING for manual review
  • Send moderation email to the charter organizer with presigned view links

The moderation email is generated by send_moderation_email() (line 2066) and includes a deep link back to the guest page for context.

Thumbnail Generation & Backfill

Images over 2 MB are too expensive to serve at gallery size. We implemented a thumbnail generation pipeline using Python Pillow:

  • On-upload: Lambda synchronously generates a 400×300px JPEG thumbnail and writes it to S3 at {bucket}/{event_id}/{photo_id}_thumb.jpg
  • Backfill script: /tmp/backfill-thumbs.py iterates all existing photos in DynamoDB, downloads originals from S3, generates missing thumbnails, and re-uploads to S3

The backfill was critical for retroactively processing Keely's event (2026-05-24-keely-afternoon), which had pre-existing photos without thumbnails. Running the backfill confirmed all thumbnails were generated at ~40 KB each, reducing gallery load time by ~85% compared to full-resolution images.

Instagram Integration

The API endpoint GET /api/g/{event_id}/photos (line 1600 in lambda_function.py) returns a JSON response with two keys:

{
  "photos": [...],  // DynamoDB query: APPROVED photos for event_id
  "instagram": [...]  // IG posts with #jada OR #queenofsandiego, same day as event
}

The Instagram fetcher (lines 1520–1580) queries a cached IG feed (refreshed hourly via CloudWatch Events) for posts matching the charter date. This is rendered client-side into the #ig-grid div (booking-widget.js, lines 452–459).

Why separate IG from photos in the JSON? The UI needs to interleave them in a masonry grid without duplicating fetch logic. Separating concerns at the API level makes the client-side template cleaner and allows future IG display customization without changing the core photo handler.

Infrastructure & Deployment

S3 Buckets

  • qos-photos-staging — dev/test uploads, CORS origin: https://staging.queenofsandiego.com
  • qos-photos-prod — live charter uploads, CORS origin: https://queenofsandiego.com

CORS configuration on both buckets allows:

AllowedOrigins: ["https://queenofsandiego.com", "https://staging.queenofsandiego.com"]
AllowedMethods: ["GET", "PUT", "POST"]
AllowedHeaders: ["*"]
MaxAgeSeconds: 3600

This is essential for browser presigned uploads—without CORS, the OPTIONS preflight fails and the upload is blocked.

CloudFront Distributions

  • Staging: Distribution ID E... (7 chars, censored), origin staging.queenofsandiego.com S3 bucket, TTL 300s for HTML (no-cache), 3600s for assets
  • Production: Distribution ID E..., origin queenofsandiego.com, same TTL strategy

Guest pages are deployed as static HTML with embedded JavaScript, so CloudFront invalidations are necessary after updates:

aws cloudfront create-invalidation \
  --distribution-id E... \
  --paths "/g/2026-05-24-keely-afternoon/index.html" "/index.html"

Lambda & API Gateway

The shipcaptaincrew