```html

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 #jada and #queenofsandiego posts 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 distribution d111111abcdef8.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 = true and rendered immediately in the gallery
  • Code missing or wrong: Photo is marked approved = false and 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_start and timestamp < event_date_end + 1 day
  • Merges results with approved photos from the queenofsandiego-photos DynamoDB 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 by event_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-index to query approved photos quickly

CloudFront:

  • Distribution ID: E1234ABCD