Building the Sailor Board: Guest Photo Upload, Moderation Queue, and Same-Day Instagram Integration for Charter Events
Queen of San Diego's charter booking flow needed a way to let guests upload photos and videos immediately after their sail, while preventing spam and surfacing user-generated content alongside official Instagram posts. This post walks through the architecture decisions, Lambda handler implementation, S3 integration, and frontend modal flow that powers the guest photo upload system—what we're calling the "Sailor Board."
What Was Built
A complete per-event guest gallery system with the following capabilities:
- Photo/Video Upload with Client-Side Validation: Guests upload up to 24 files at once (JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM)
- Event Code Gate: Guests with the event access code bypass moderation; guests without it enter a review queue
- Same-Day Instagram Integration: Lambda fetches posts tagged #jada or #queenofsandiego on the charter date and renders them alongside guest uploads
- Per-Event Gallery Page: Deployed to S3 at URLs like
/g/{event_date}-{guest_name}, accessible via CloudFront - Presigned URL Upload Flow: Client requests presigned URLs from Lambda, uploads directly to S3, avoiding server-side multipart handling
Technical Architecture
Frontend: Guest Page and Modal Flow
The guest page lives at /Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html and is deployed to S3 as a template. The page includes:
#file-input(line 362): Hidden file input accepting multiple files#g-code(line 354): Event access code field#photo-grid(lines 452–459): Container for both guest uploads and Instagram postshandleFiles()(line 514): Loops through selected files, callsuploadFile()for each, capping at 24readCode()(line 424): Stores the event code in localStorage for later presign requests
When a guest selects files, the frontend immediately makes a request to the Lambda presign endpoint with the event ID, event code (if provided), and file metadata. The Lambda responds with a presigned POST URL and form fields. The client then uploads directly to the S3 bucket, bypassing the Lambda for the actual file transfer—this avoids Lambda timeout issues and keeps request/response sizes manageable.
Why presigned URLs instead of server-side upload? Lambda has a 30-second timeout and a 6 MB response payload limit. Video files easily exceed these constraints. By issuing presigned URLs, we offload the upload burden to S3 and let clients handle retries and resumable uploads via the browser's native fetch API.
Lambda Handler: Photo Ingestion and IG Integration
The core logic lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. The handler exposes several endpoints:
POST /api/g/{event_id}/presign: Issues presigned URLs for file uploadsGET /api/g/{event_id}/photos: Returns a JSON object with two arrays:d.guest(uploaded files, sorted by approval status) andd.instagram(fetched IG posts from the event date)POST /api/g/{event_id}/publish: Called by the frontend after upload; marks the file as pending moderation or live depending on whether the event code was valid
Presign Endpoint Logic:
# Validate event_id exists in DynamoDB charter table
# If event_code matches charter.access_code, mark uploaded file as approved: true
# Else mark as approved: false (moderation queue)
# Generate presigned POST URL with:
# - Bucket: ${BUCKET_NAME}
# - Key: g/{event_id}/{file_uuid}.{ext}
# - Content-Type: validated against allowed MIME types
# - Expiration: 3600 seconds
# Return presigned form fields + policy + signature
Files are stored in the S3 bucket (environment variable BUCKET_NAME, typically qos-charter-uploads-prod) under the prefix g/{event_id}/. After successful upload, the client calls the publish endpoint, which writes a DynamoDB record to the charter event's photo index table with metadata (file size, MIME type, approval status, timestamp).
Instagram Integration: The GET /api/g/{event_id}/photos endpoint:
- Extracts the event date from event_id (e.g.,
2026-05-24from2026-05-24-keely-afternoon) - Calls the Instagram Graph API with filters for
#jadaand#queenofsandiegohashtags posted on that date - Fetches the Instagram account credentials from Secrets Manager (cached to avoid repeat calls)
- Returns posts as an array of objects with
caption,image_url,posted_at, andusername - Merges guest and IG arrays, returning them under the
d.instagramkey
Why fetch IG on the read path instead of during upload? Instagram's Graph API has rate limits, and we want to show the most current posts. Caching IG results for 5 minutes in DynamoDB avoids hammering the API on every page load, but still refreshes frequently enough to feel live.
Spam Prevention: Event Code as Gate
The event access code (stored in charter.access_code in DynamoDB) is the trust boundary. Guests receive it in the booking confirmation email. When they enter it in the #g-code field and select files, the frontend includes it in the presign request. The Lambda compares the submitted code to the charter's code:
- Match: Presigned file is marked
approved: true. It appears live on the page immediately after upload. - No match or missing: File is marked
approved: false. It enters a moderation queue and an email notification is sent to the charter organizer with an admin link to approve/reject.
This design allows a charter to be discoverable (guests can visit /g/{event_id} without credentials) while still gating uploads to people with the booking code. Spam bots might find the URL but can't upload without the code—and if they do submit without it, organizers review before publishing.
24-File Concurrent Upload Cap
The frontend enforces a hard cap: Array.from(files).slice(