Building the Sailor Board: Implementing Post-Sail Photo Galleries with Event-Scoped Upload Controls
This post documents the design and deployment of the Sailor Board feature—a per-charter photo gallery system with spam-resistant uploads, real-time Instagram integration, and event-scoped moderation workflows. We'll cover the architecture decisions, infrastructure wiring, and the multi-stage deployment process that brought it from concept to production.
What We Built
The Sailor Board is a guest-facing photo gallery deployed at per-event URLs like /g/{event_id} that guests access during and after their sail. It has four core capabilities:
- Authenticated photo/video uploads with 24-file batch caps and format validation (JPEG/PNG/WebP/HEIC/HEIF + MP4/MOV/AVI/WebM)
- Event code spam gate—guests with the event code bypass moderation; guests without it go to a review queue
- Same-day Instagram hashtag aggregation pulling
#jadaand#queenofsandiegoposts during the charter window - Per-event moderation workflows with email notifications, approval toggles, and captain-controlled display settings
The feature launched live on the Keely afternoon charter event (2026-05-24-keely-afternoon) and is now serving as the template for all future events.
Architecture Overview
The Sailor Board splits responsibility across three layers:
- Client tier: Static HTML/CSS/JS deployed to CloudFront at
queenofsandiego.com/g/{event_id}/index.html - API tier: AWS Lambda handler at
shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/photosmanaging presigned uploads, photo fetches, and IG aggregation - Storage tier: DynamoDB for metadata (photo records, event config, moderation state), S3 buckets for raw media (segregated by environment: staging vs. prod)
Client-Side Implementation
The guest page HTML lives at /Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html and is deployed to S3 + CloudFront. Key implementation details:
# File inputs and form structure
<input type="file" id="file-input" accept="image/*,video/*" multiple />
<input type="text" id="g-code" placeholder="Event code" />
# Line 514-515: Upload handler with 24-file cap
Array.from(files).slice(0, 24).forEach(uploadFile)
# Line 452-459: Instagram grid rendered from server response
Object.values(d.instagram).forEach(post => {
// Render IG grid item with photo, caption, timestamp
})
The page uses a conditional rendering system based on FLIP_UTC (the event end time in UTC). Before sail time, the upload UI is hidden; after sail time, it's visible. For Keely's event, FLIP_UTC = 2026-05-25T00:00Z (May 24 at 5 PM Pacific), which flipped the page to post-sail mode immediately after the sail ended.
The upload function generates presigned S3 URLs by calling the Lambda endpoint, then streams files directly to S3 using the presigned PUT URL. This keeps the Lambda function lightweight and avoids payload limits.
Lambda Photo Handler
The core API logic lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. The handler provides three key operations:
POST /api/g/{event_id}/photos/presign— Issues presigned S3 PUT URLs for direct uploadsGET /api/g/{event_id}/photos— Returns paginated photo list + Instagram feed for the eventPOST /api/g/{event_id}/photos/moderate— Captain-only endpoint for approval/rejection
The presign endpoint validates the incoming event code against a DynamoDB table (PhotoGalleries) and branches based on result:
# Pseudocode: Presign handler logic
def presign_handler(event_id, guest_code):
event_config = get_event_config(event_id) # DynamoDB lookup
if guest_code == event_config['code']:
approval_status = 'approved' # Bypass queue
else:
approval_status = 'pending' # Goes to review
# Generate S3 PUT presigned URL valid for 15 minutes
presigned_url = s3_client.generate_presigned_url(
'put_object',
Bucket=PROD_BUCKET,
Key=f"g/{event_id}/{photo_id}.jpg",
ExpiresIn=900
)
return {'url': presigned_url, 'status': approval_status}
The GET photos endpoint aggregates three sources: approved photos from DynamoDB, pending photos (for captain view), and Instagram posts. The IG integration fetches from the Instagram Insights API using credentials stored in Lambda environment variables, filters for posts tagged with #jada or #queenofsandiego created during the event's date window, and returns them in the same JSON shape as uploaded photos.
Infrastructure & Deployment
The Lambda function is deployed as a zipped package via the deploy_shipcaptaincrew.sh script. The deployment process:
- Bundles the Python source + vendored dependencies (e.g.,
py_vapidfor push notifications) into a zip file - Uploads the zip to S3 at
s3://deployment-artifacts-prod/shipcaptaincrew-{timestamp}.zip - Updates the Lambda function code using the AWS CLI
- Validates the function syntax and runs a smoke test against the existing photos endpoint
The guest page itself is deployed separately. After creating the sailor-board/ directory, the build process:
- Uploads
index.htmltos3://qos-prod/g/{event_id}/index.html - Invalidates the CloudFront distribution (ID:
E{prod-dist-id}) with path/g/{event_id}/* - Verifies the page is live by hitting the staging CloudFront distribution first, then prod
The booking modal deep-linking required careful JS ordering. The modal initialization is injected into the homepage (/Users/cb/Documents/repos/sites/queenofsandiego.com/index.html) and listens for URL query parameters (?open=booking). The "Book a Sail" button now passes this parameter instead of linking to a separate checkout page, ensuring the modal opens in-place without navigation.
S3 CORS configuration was updated to allow cross-origin presigned uploads from queenofsandiego.com:
# S3 CORS rule for presigned uploads
{