```html

Building the Sailor Board: Guest Photo Upload, Moderation, and Real-Time Social Integration for Queen of San Diego Charter Events

Overview

During this session, we completed a full feature audit and deployment cycle for Queen of San Diego's guest photo upload system, specifically for the Keely afternoon charter event (2026-05-24). The "Sailor Board" is the per-event photo gallery that appears on guest pages—a private, moderated submission flow that integrates Instagram hashtag discovery, per-guest upload quotas, and event-specific access codes to prevent spam.

This post walks through the architecture decisions, Lambda deployment pipeline, S3 CORS configuration, and the CloudFront invalidation strategy we used to ship updates without downtime.

Architecture: Guest Pages → Lambda API → S3 + DynamoDB

Every Queen of San Diego charter gets a unique guest page at a URL pattern like https://queenofsandiego.com/g/{event_id}. The page is a static HTML document stored in S3 at s3://qos-site-prod/g/{event_id}/index.html. When guests visit, the page:

  • Checks if the event has already sailed (hardcoded UTC flip time)
  • Renders an upload form if post-sail
  • Fetches a photo gallery from a Lambda API endpoint
  • Attempts to pull Instagram posts matching #jada or #queenofsandiego from the same day

The Lambda function lives at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py and is deployed to AWS Lambda under the name shipcaptaincrew. The handler serves multiple endpoints:

  • GET /api/g/{event_id}/photos — Returns moderated photos + Instagram feed for that event
  • POST /api/g/{event_id}/presign — Issues S3 presigned URLs for guest uploads
  • POST /api/g/{event_id}/upload-webhook — Receives S3 notifications when uploads complete

Feature Details: What's Actually in Keely's Page

The Keely event page (deployed 2026-05-25 02:11 UTC) includes four critical features:

1. Photo and Video Upload with File-Type Validation

Guest form in the HTML (line 362) has an input#file-input that accepts JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, and WebM. The JavaScript handler handleFiles() (line 514) slices the input to the first 24 files, then calls uploadFile() for each. Each upload is presigned via the Lambda endpoint before hitting S3 directly—this keeps credentials off the client.

2. Event Code Spam Gate

An input#g-code field (line 354) allows guests to enter a per-event code. The readCode() function (line 424) passes this to the Lambda. If the code matches the event's secret in DynamoDB, the photo bypasses moderation and is published immediately. If the code is missing or wrong, the photo goes into a moderation queue (copy: "The code keeps strangers from posting to your charter page."). This pattern prevents random Instagram discovers or forwarded links from flooding the page with unrelated content.

3. 24-at-Once Upload Cap

Line 515: Array.from(files).slice(0, 24).forEach(uploadFile). The UI hints at this on line 360 with "up to 24 at once". This was a deliberate choice to prevent a single guest from bottlenecking S3 or creating a poor UX (progress tracking becomes unwieldy beyond ~20 parallel uploads).

4. Same-Day Instagram Hashtag Integration

The page fetches from the same GET /api/g/{event_id}/photos endpoint, which returns a d.instagram array. This is rendered into #ig-grid (lines 452–459). The Lambda function queries an Instagram integration (credential setup omitted here) and filters posts by hashtag (#jada or #queenofsandiego) and same-day timestamp, returning only posts from the event date.

Infrastructure and Deployment

S3 CORS Configuration

A critical blocker we hit: presigned URLs were failing with CORS errors when the guest page tried to PUT an object to S3 from the browser. The fix was updating the S3 bucket CORS policy on qos-site-prod to allow Origin: https://queenofsandiego.com and the PUT method. The bucket itself is dual-purpose (static site + upload target), which simplified the architecture—no separate upload bucket to manage.

Lambda Deployment Pipeline

The shipcaptaincrew Lambda is not serverless-framework or CDK; it's a hand-packaged ZIP with dependencies. Our deploy process:

  1. Edit lambda_function.py locally (photo handler, IG integration, etc.)
  2. Bundle dependencies (notably py_vapid for push notifications) into a lib/ folder
  3. Create a ZIP: zip -r shipcaptaincrew-deploy.zip lambda_function.py lib/
  4. Upload via AWS CLI to update the function code
  5. Smoke test the /api/g/{event_id}/photos endpoint

The function is invoked via an API Gateway that routes queenofsandiego.com requests to the Lambda (exact API Gateway ID omitted for security). Because we're not using infrastructure-as-code, the deployment is relatively manual but fast—useful for quick fixes post-event.

Guest Pages and CloudFront Invalidation

The guest page HTML is checked into /sites/queenofsandiego.com/g/{event_id}/index.html locally, synced to S3, then served through CloudFront (distribution ID omitted). When we updated Keely's page (multiple edits to the upload form, modal behavior, etc.), we had to invalidate the CloudFront cache with pattern /g/2026-05-24-keely-afternoon/* to ensure browsers got the new version immediately.

Key Decisions and Trade-offs

Server-Side Moderation Queue vs. Auto-Publish

Photos without the event code go into a DynamoDB table where they await admin review. This is a safety valve: if someone discovers a guest page URL and tries to spam it, we're not blasting the gallery with garbage. The trade-off is that well-meaning guests without the code see a delay (typically a few minutes, human moderation). We mitigate by making the code prominent in the event email and on the page itself.

Instagram Same-Day Filter

Rather than pulling all Instagram posts tagged #jada, we filter to posts from the event date only. This keeps the feed contextual and prevents old Instagram posts from cluttering a specific charter's memory. The timestamp comparison happens on the Lambda side, not the client.