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
#jadaor#queenofsandiegofrom 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 eventPOST /api/g/{event_id}/presign— Issues S3 presigned URLs for guest uploadsPOST /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:
- Edit
lambda_function.pylocally (photo handler, IG integration, etc.) - Bundle dependencies (notably
py_vapidfor push notifications) into alib/folder - Create a ZIP:
zip -r shipcaptaincrew-deploy.zip lambda_function.py lib/ - Upload via AWS CLI to update the function code
- Smoke test the
/api/g/{event_id}/photosendpoint
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.