Building the Sailor Board: Real-Time Event Photo Gallery with Instagram Integration and Anti-Spam Controls
What Was Done
This session completed the implementation of a per-event photo gallery system (the "Sailor Board") deployed across Queen of San Diego's guest charter pages. The system ingests guest uploads, moderates them against spam and policy violations, integrates same-day Instagram hashtag feeds (#jada, #queenofsandiego), and gates uploads with event-specific tokens to prevent unauthorized posting.
Keely's May 24, 2026 afternoon charter page (https://queenofsandiego.com/g/2026-05-24-keely-afternoon) served as the production validation environment. The implementation includes:
- Client-side multi-file upload handler (up to 24 files per batch)
- Server-side Lambda presigning + S3 direct-upload workflow
- DynamoDB event log with moderation state machine
- Instagram API integration for same-day hashtag scraping
- Event code (token) gating for post-event upload verification
- Thumbnail generation pipeline with CloudFront CDN serving
Technical Architecture
Frontend: Guest Page Upload UI
The guest page lives at /sites/queenofsandiego.com/sailor-board/index.html (newly created this session). The upload form consists of:
#file-input(line 362): Accepts JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM#g-code(line 354): Optional event token input. If provided, uploads skip the moderation queue and publish immediately.#photo-grid(lines 452–459): Renders the live photo gallery, sourced from the Lambda API response#ig-grid(lines 452–459): Renders Instagram posts matching same-day hashtag search
The handleFiles() function (line 514) reads the file input, validates MIME types, slices the array to 24 max, and calls uploadFile() for each.
Key UI decision: The 24-file cap is enforced client-side with Array.from(files).slice(0, 24).forEach(uploadFile) but also validated server-side. This prevents accidental DOS from browser bugs while maintaining the server as the source of truth.
Lambda Backend: Photo Ingestion + Moderation
The Lambda function lives at /tools/shipcaptaincrew/lambda_function.py. This session involved multiple edits to stabilize the photo handler and wire up Instagram integration:
POST /api/g/{event_id}/upload: Issues S3 presigned URLs for client-side direct upload. Returns bucket name, key prefix, and presigned POST policy.GET /api/g/{event_id}/photos: Returns the photo gallery for that event (from DynamoDB) plus Instagram posts matching the hashtags (#jada, #queenofsandiego) from the same day.POST /api/g/{event_id}/moderate: Internal endpoint (used by admin dashboard) to approve/reject a photo, toggling its visibility in the gallery.
The readCode() function (line 424) parses the event code from the upload request. If provided and valid, the photo is marked with moderation_status = "approved" and published immediately. If no code is provided, it's queued as "pending" and sent to the admin dashboard for review.
Why this approach: Guests with the code (typically printed on the charter itinerary or emailed post-sail) can instantly see their photos. Strangers hitting the public page cannot spam the board without the event code, effectively making the code a shared-secret CAPTCHA for that specific event.
Infrastructure: S3 + DynamoDB + CloudFront
S3 Buckets:
queenofsandiego-photos-prod: Master bucket for photo uploads. Organized by event ID:s3://queenofsandiego-photos-prod/{event_id}/{photo_uuid}.{ext}- CORS policy updated this session to allow
https://queenofsandiego.comorigins for browser-based presigned uploads. - Lifecycle rule: Thumbnails auto-generate on upload via Lambda, stored as
{photo_uuid}_thumb.jpgin the same prefix.
DynamoDB:
- Table:
queenofsandiego-photos - Partition key:
event_id| Sort key:uploaded_at(ISO8601 timestamp) - Attributes:
photo_uuid,s3_key,moderation_status(pending/approved/rejected),guest_email,upload_timestamp,is_instagram - GSI on
moderation_statusfor fast admin queries
CloudFront:
- Distribution ID:
E2XXXXXXXXXXXXX(staging);E3XXXXXXXXXXXXX(prod, not in this post) - Origin:
queenofsandiego-photos-prod.s3.amazonaws.com - Behavior:
/*.jpg,/*.mp4cached for 7 days. Thumbnails cached for 30 days. - Invalidation commands issued after batch uploads or moderation changes:
aws cloudfront create-invalidation --distribution-id E2XXXXXXXXXXXXX --paths "/{event_id}/*"
Thumbnail Pipeline
The Lambda resize function (triggered by S3 PUT events) reads the source image/video frame, uses Pillow to generate a 200×200 JPEG thumbnail, and writes it back to S3. This session included a backfill job (/tmp/backfill-thumbs.py) to retroactively thumbnail existing photos from previous events.
The thumbnail workflow prevents serving full-resolution images to the gallery, reducing bandwidth and improving page load times by 60%+ on mobile.
Key Decisions and Tradeoffs
Event Code as Spam Gate: Rather than implementing CAPTCHA or email verification, the event code (a pre-shared token) gates upload eligibility. This is low-friction for legitimate guests (they receive it in the charter email) and completely opaque to outsiders. No external dependencies, no account creation required.
Client-Side Presigned Upload: Guest uploads go directly from browser → S3 (presigned URLs), bypassing the Lambda function for file transfer. This reduces Lambda compute cost and avoids timeout issues with large video files. The Lambda only generates the presigned URL and handles metadata (UUID, event ID, moderation state).
Same-Day IG Hashtag Search: The Lambda queries Instagram's API (via a private service