Building the Sailor Board: Photo Upload Gallery, Event Codes, and Instagram Integration for Charter Guest Pages
Over the last development session, we completed a full-stack implementation of the Sailor Board feature—a per-event photo gallery system that allows charter guests to upload photos and videos during and after their sail, with server-side Instagram hashtag aggregation, spam prevention via event codes, and a 24-file batch upload cap. This post covers the architecture, infrastructure decisions, and deployment pipeline.
What Was Built
The Sailor Board is not a global leaderboard or cross-charter aggregate. Instead, it's a per-event guest page that lives at /g/{event_id} and serves as both an upload portal and a gallery. When deployed for an event like Keely's 2026-05-24 afternoon charter, it appears at:
https://queenofsandiego.com/g/2026-05-24-keely-afternoon
The page includes four core features:
- Photo/video upload with JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM support
- Event code validation to gate uploads and prevent spam
- 24-file batch limit to prevent resource exhaustion
- Same-day Instagram hashtag pull for #jada and #queenofsandiego posts
Frontend Architecture
The guest page is generated as a standalone HTML file and deployed to S3. The implementation lives in:
/Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html
This file is served directly from S3 via CloudFront (staging distribution alias: d111111.cloudfront.net, invalidated after each deploy). The page is a single-file SPA with embedded JavaScript and CSS—no external dependencies beyond the AWS SDK for signed uploads.
Key form elements:
#file-input(line 362): Multi-file input accepting JPEG/PNG/WebP/HEIC/HEIF + video formats#g-code(line 354): Event code input for spam gating#photo-grid(lines 452–459): Gallery render zone for uploaded photos and Instagram posts#ig-grid: Dedicated zone for Instagram feed
Upload flow:
- User selects up to 24 files via
#file-input handleFiles()(line 514) slices array to first 24:Array.from(files).slice(0, 24).forEach(uploadFile)- For each file,
uploadFile()calls the presigner endpoint:GET /api/presign?event_id={event_id}&filename={filename}&mime={mime} - Lambda returns a signed S3 POST URL
- Browser uploads directly to S3 bucket
qos-photos-prod(orqos-photos-stagingfor testing) - S3 object creation triggers a Lambda that writes metadata to DynamoDB table
qos_photos_prodand generates thumbnails
Event code gating:
The readCode() function (line 424) checks if a code is provided. If present, the upload bypasses the JADA review queue and goes straight to status: "published" in DynamoDB. If absent, uploads are flagged as status: "review" and a moderation email is sent to the event organizer. This is enforced server-side in the Lambda presigner and the S3 object handler.
Instagram integration:
The page renders Instagram posts into the gallery via data served from the Lambda endpoint:
GET /api/g/{event_id}/photos
The endpoint returns a JSON object with:
{ "photos": [...], "instagram": [...] }
The d.instagram array is populated by the Lambda's hashtag fetcher, which queries Instagram's public API (via a custom IG App credential) for posts matching #jada and #queenofsandiego posted on the event date. This is wired into tools/shipcaptaincrew/lambda_function.py, specifically the /api/g/{event_id}/photos handler.
Backend: Lambda and S3 Orchestration
Presigner endpoint:
The Lambda function at tools/shipcaptaincrew/lambda_function.py exposes:
GET /api/presign?event_id=2026-05-24-keely-afternoon&filename=photo.jpg&mime=image/jpeg
This endpoint:
- Validates the event ID against the
qos_eventsDynamoDB table - Generates a signed S3 POST form with a 15-minute TTL
- Returns the POST URL and form fields so the browser can upload directly to S3
- Sets the S3 object key to a deterministic pattern:
g/{event_id}/{uuid4()}/{filename}
Photo metadata handler (S3 event):
When an object lands in qos-photos-prod, an S3 event triggers a Lambda function that:
- Extracts the
event_idandfilenamefrom the object key - Reads the object metadata (EXIF, size, MIME type)
- Validates the event code from a custom header or query param (if provided during presign)
- Writes a record to DynamoDB
qos_photos_prodwithstatus: "published"or"review" - Generates thumbnails (320px, 640px) via PIL/Pillow and stores them in the same S3 key prefix
- If status is
"review", sends a moderation email to the event organizer with a magic link to approve/reject
Thumbnail generation:
Thumbnails are generated synchronously in the Lambda (no SQS queue). The Lambda runtime is Python 3.11 with Pillow 10.x bundled in the deployment zip. After a failed initial deploy, we ran a backfill script (/tmp/backfill-thumbs.py) to retroactively generate thumbs for existing photos. This was necessary because early uploads predated the thumbnail logic.
Infrastructure and Deployment
S3 buckets:
qos-photos-prod: Production photo uploads (object lifecycle: 90 days retention, then delete)qos-photos-staging