```html

Building the Sailor Board: Event Photo Galleries with Real-Time Instagram Integration

Over the past development session, we completed the infrastructure and application logic to support per-event photo galleries ("Sailor Boards") with real-time Instagram hashtag integration, upload moderation queues, and guest code spam prevention. This post documents the technical architecture, deployment decisions, and integration patterns we implemented.

What Was Built

The Sailor Board is a per-event photo gallery system deployed at /g/{event_id} on the main Queen of San Diego website. Guests can upload photos and videos directly, moderators can approve/reject submissions via email, and the system automatically fetches and displays Instagram posts tagged with event-specific hashtags from the same day.

Example live deployment: https://queenofsandiego.com/g/2026-05-24-keely-afternoon

Core features:

  • Photo/Video Upload: Guests upload JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, or WebM files directly to S3 via presigned URLs
  • Event Code Gate: A numeric code provided to guests allows instant publication; submissions without the code go to a moderation queue
  • Batch Upload: Support for uploading up to 24 files at once
  • Instagram Integration: Server-side hashtag search pulls posts tagged #jada or #queenofsandiego from the charter date and renders them in the gallery
  • Thumbnail Generation: Automatic thumbnail backfill for video uploads to reduce client-side load

Architecture Overview

Frontend: Per-Event Guest Pages

Guest pages are static HTML deployed to S3 and served via CloudFront. Example: /sites/queenofsandiego.com/g/2026-05-24-keely-afternoon/index.html

Key page elements:

  • #file-input (line 362): File picker accepting multi-select upload
  • #g-code (line 354): Text input for event code validation
  • handleFiles() (line 514): Orchestrates presign request → S3 upload → publish or queue for moderation
  • #ig-grid (lines 452–459): Rendered from server-provided d.instagram array
  • #photo-grid: Gallery of approved uploads from DynamoDB

Why static HTML: Event pages are generated once per charter and rarely change during the event window. This lets us cache aggressively in CloudFront (default 1-hour TTL, with manual invalidation for urgent moderation updates) and avoid Lambda cold starts on every page load.

Backend: Lambda + DynamoDB + S3

Lambda handler: /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py

This function handles three main routes:

  • POST /api/g/{event_id}/upload: Request a presigned S3 URL for a given file. Validates event code if provided. Returns { "url": "...", "fields": {...} } for browser-based multipart upload.
  • GET /api/g/{event_id}/photos: Fetch approved photos from DynamoDB table queenofsandiego-photos and Instagram data (if available). Returns JSON array with photo metadata, thumbnail URLs, and Instagram posts.
  • POST /api/g/{event_id}/publish (internal): Called after successful S3 upload to either publish immediately (if event code matched) or queue for moderation.

DynamoDB schema: Table queenofsandiego-photos stores:

  • PK: event#{event_id}
  • SK: photo#{timestamp}#{uuid}
  • status: "published" | "pending_review"
  • s3_key: Full S3 object path
  • thumb_s3_key: Thumbnail object path (for videos)
  • instagram_post_id: Populated by server-side IG hashtag scanner

S3 buckets:

  • queenofsandiego-guest-uploads: Raw uploaded files. Lifecycle policy archives to Glacier after 30 days.
  • queenofsandiego-site-static: Guest page HTML + JS assets. CloudFront distribution E2Y4X5Z9B1C2D (staging) and production distribution serve these files.

CORS configuration: Updated S3 bucket queenofsandiego-guest-uploads to allow presigned POST requests from both queenofsandiego.com and staging domain:

AllowedOrigins: ["https://queenofsandiego.com", "https://staging.queenofsandiego.com"]
AllowedMethods: ["PUT", "POST"]
AllowedHeaders: ["*"]
MaxAgeSeconds: 3600

Instagram Integration

On each GET /api/g/{event_id}/photos request, the Lambda checks if Instagram hashtag data has already been fetched for that event date. If not (or on forced refresh), it:

  1. Calls Instagram Graph API with hashtag IDs for #jada and #queenofsandiego
  2. Filters posts to those published on the event date (e.g., 2026-05-24)
  3. Caches results in DynamoDB with a TTL of 24 hours to avoid redundant API calls
  4. Returns the array as part of the photos response JSON

Why server-side: Instagram API requires a long-lived token that must not be exposed to browsers. The client-side page simply renders whatever d.instagram array is provided by the Lambda response.

Key Technical Decisions

Event Code Validation

Rather than storing a plaintext code in the HTML, we hash the code on the client and compare against a hash stored in DynamoDB. This prevents network sniffing of the code if HTTPS were ever compromised:

// Client-side (booking-widget.js)
const hashedCode = sha256(userInputCode);
await fetch('/api/g/{event_id}/upload', {
  method: 'POST',
  body: JSON.stringify({ code_hash: hashedCode, filename: ... })
});

// Lambda side
stored_hash = get_event_code_hash(event_id)
if sha256(provided_code) == stored_hash:
    instant_publish = True
else:
    instant_publish = False  // Queue for moderation

Thumbnail Generation and