Building the Sailor Board: Event Photo Gallery with Real-Time Instagram Integration and Spam Prevention
During the Keely afternoon charter (2026-05-24), we deployed a complete guest photo-sharing system with built-in Instagram hashtag aggregation and event-code-gated moderation. This post covers the architecture, deployment strategy, and the infrastructure decisions that made same-day social integration possible.
What Was Built
The "Sailor Board" is a per-charter guest page deployed to https://queenofsandiego.com/g/{event_id} that combines three capabilities:
- Guest photo uploads: Direct file submission (up to 24 at once) in JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, or WebM formats
- Event-code spam gate: Guests with the event code publish instantly; others queue for moderator review
- Same-day Instagram hashtag pull: The Lambda backend queries Instagram for
#jadaand#queenofsandiegoposts from the charter date and auto-injects them into the gallery
File Structure and Deployment Layout
The guest page source lives in two places depending on deployment phase:
/Users/cb/Documents/repos/sites/queenofsandiego.com/g/{event_id}/index.html— Local development source (created per-event or pulled from S3 for recovery)s3://queenofsandiego-public/g/{event_id}/index.html— Production S3 bucket, served through CloudFront distributiond111111abcdef8.cloudfront.net
For Keely's charter, the page was 22.5 KB and deployed 2026-05-25 02:11 UTC (post-event). The source was generated without a local checkin, so we recovered it from S3 for audit:
aws s3 cp s3://queenofsandiego-public/g/2026-05-24-keely-afternoon/index.html ./g/2026-05-24-keely-afternoon/index.html
Photo Upload Handler: Frontend + Backend Flow
The upload form lives in the guest page HTML (around line 362) with an #file-input element. When a user selects files, the handleFiles() function (line 514) kicks off:
Array.from(files).slice(0, 24).forEach(uploadFile)
This caps submissions at 24 files per batch (enforced client-side and validated server-side). Each file is sent to a pre-signed S3 upload URL obtained from:
GET https://shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/presign
The presign endpoint is defined in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py around line 1800. It validates the request origin (CORS-gated to queenofsandiego.com), generates an S3 PUT URL good for 15 minutes, and returns it. The Lambda function role has s3:PutObject on arn:aws:s3:::queenofsandiego-uploads/{event_id}/*.
After upload, an S3 trigger fires a second Lambda (the same function, routed internally) to:
- Generate a thumbnail (using Pillow, Python Imaging Library)
- Write metadata to DynamoDB table
queenofsandiego-photos - Queue the photo for moderation if the uploader didn't provide the event code
Event Code and Moderation Gate
The event code input sits at line 354 as #g-code. Its value is read by readCode() (line 424) and sent with each upload request. The Lambda handler checks it against the event record in DynamoDB:
- Code matches: Photo is marked
approved = trueand rendered immediately in the gallery - Code missing or wrong: Photo is marked
approved = falseand lands in the moderation queue at the admin dashboard
This approach prevents random strangers from posting while allowing genuine guests (who received the code via email or in-person) to contribute freely. The moderation queue is polled by an internal admin tool that sends approval/rejection emails to the uploader.
Instagram Hashtag Aggregation
The page HTML includes a placeholder #ig-grid (lines 452–459) that renders whatever the backend returns in the d.instagram array. The data is populated by calling:
GET https://shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/photos
The response includes both user uploads and Instagram posts matching #jada or #queenofsandiego from the charter date. The Lambda handler:
- Queries the IG Graph API (credentials stored in AWS Secrets Manager) for posts tagged with both hashtags
- Filters by
timestamp ≥ event_date_startandtimestamp < event_date_end + 1 day - Merges results with approved photos from the
queenofsandiego-photosDynamoDB table - Returns a mixed array sorted by timestamp (newest first)
This gives guests a real-time social aggregation—they can see both their own uploads and public Instagram posts in one place, encouraging them to use the hashtags and cross-promote.
Infrastructure and Deployment
S3 Buckets:
queenofsandiego-public— Guest pages, assets (CloudFront origin)queenofsandiego-uploads— User-submitted photos, partitioned byevent_id/queenofsandiego-staging— Pre-production versions
Lambda Functions:
shipcaptaincrew(main handler) — ~450 KB zipped with dependencies (py_vapid for web push, boto3, requests). Deployed via CodeDeploy or manual zip upload.- Environment variables:
UPLOADS_BUCKET,PUBLIC_BUCKET,DYNAMODB_TABLE,IG_GRAPH_URL
DynamoDB:
- Table:
queenofsandiego-photos - Partition key:
event_id#photo_id - GSI:
approved-created-indexto query approved photos quickly
CloudFront:
- Distribution ID:
E1234ABCD