Building Keely's Guest Photo Upload Page: Architecture, Lambda Integration, and Real-Time Instagram Feed Sync
What We Built
Keely's guest page (https://queenofsandiego.com/g/2026-05-24-keely-afternoon) is a post-charter photo submission and curation system that combines client-side form handling, serverless Lambda processing, S3 storage, and real-time Instagram hashtag integration. The page went live May 25, 2026, just after her afternoon sail completed.
The system addresses a critical gap in the charter experience: guests want to share photos immediately post-sail, but operators need spam protection and photo curation before public display. We solved this with event-scoped access codes, asynchronous Lambda processing, and an optional same-day Instagram feed pull tied to charter hashtags.
Architecture Overview
Frontend: Per-Event Guest Page
The guest page lives in S3 at s3://queenofsandiego-web/g/2026-05-24-keely-afternoon/index.html (22.5 KB). It's a standalone HTML file with embedded CSS and vanilla JavaScript—no build step, no dependencies beyond the browser and AWS SDK.
- Upload form (
#file-input, line 362): Accepts JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM. Enforces 24-file-at-once limit viaArray.from(files).slice(0, 24).forEach(uploadFile)(line 515). - Spam gate (
#g-code, line 354): Optional event-scoped code input. Code present → immediate publish to#photo-grid. Code absent → submission queued for operator review in DynamoDB with statuspending_review. - Instagram grid (
#ig-grid, lines 452–459): Server-rendered list of same-day Instagram posts matching charter hashtags (#jada, #queenofsandiego). Data supplied by Lambda at request time and baked into the page HTML. - Gallery (
#photo-grid): Live-updates via client-side polling or manual refresh after uploads complete.
Backend: shipcaptaincrew Lambda
The workhorse is /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. It's a single Python file deployed as a Lambda function behind API Gateway, handling multiple routes:
POST /api/g/{event_id}/presign: Returns a presigned S3 PUT URL so the browser can upload directly to the bucket without backend mediation.GET /api/g/{event_id}/photos: Returns all approved photos + same-day Instagram posts for that charter event (merged into a singled.instagramarray on the client).POST /api/g/{event_id}/submit: Accepts photo metadata (filename, caption, code) and inserts a record into DynamoDB with statusapproved(if code matches) orpending_review(if code missing/wrong).POST /api/g/{event_id}/moderate: Operator endpoint to approve/reject pending submissions and send notification emails.
Data Flow: Upload → Publish
Happy path (code provided):
- Guest selects files and enters event code in the form.
- Frontend calls
readCode()(line 424) to validate format and trim whitespace. - For each file, frontend calls
POST /presignwith the event code. Lambda verifies code against DynamoDB event record (table:Events, keyevent_id#2026-05-24-keely-afternoon) and returns a presigned URL valid for 10 minutes. - Frontend uploads file directly to S3 (bucket:
queenofsandiego-photos, key:{event_id}/{uuid}.{ext}). - Frontend calls
POST /submitwith metadata. Lambda verifies code again and inserts a photo record into DynamoDB tablePhotoswithstatus = "approved"andttl = event_end_time + 90 days. - Photo appears in gallery within seconds.
Spam path (no code or wrong code):
- Guest uploads without entering a code.
- Lambda inserts record with
status = "pending_review". - Photo is hidden from the gallery until operator reviews and approves via the
POST /moderateendpoint. - Approval email sent to guest: "Your photo is live. Welcome to the sailor board." with a link back to the charter page.
Instagram Integration: Server-Side Hashtag Fetch
The GET /api/g/{event_id}/photos endpoint does more than return S3 photos. It also queries Instagram (via the Instagram Graph API, credentials stored in Secrets Manager) for posts matching #jada and #queenofsandiego from the same calendar day as the charter event.
The results are merged into a single response object:
{
"photos": [
{"id": "...", "url": "...", "status": "approved", "source": "s3"},
...
],
"instagram": [
{"id": "...", "url": "...", "caption": "...", "source": "instagram"},
...
]
}
The client renders both arrays side-by-side in the grid, so guests see a unified feed: their own uploaded photos plus any public Instagram posts from that day with the charter hashtags. This creates social proof and encourages tag participation.
Infrastructure & Deployment
S3 Buckets
queenofsandiego-web: Holds all guest pages, index pages, and static assets. CORS configured to allow originhttps://queenofsandiego.comfor presigned uploads.queenofsandiego-photos: Raw photo storage. Private, no public read ACL. Access only via CloudFront distributionphotos.queenofsandiego.com(distribution ID:E2V3K..., masked) or presigned URLs.
DynamoDB Tables
Photos: Schema =event_id#timestamp (pk), photo_id (sk), status, source, ttl, metadata. TTL attribute set to 90 days post-event for automatic cleanup.Events: Schema =event_id (pk), event_code, event_start, event_end, charter_name. Seed this table when creating a new charter in the booking system.
Lambda & API Gateway
Function: shipcaptaincrew (Python 3.11 runtime, 512 MB memory, 60