```html

Building the Sailor Board: Photo Upload Gallery, Event Codes, and Instagram Integration for Charter Guest Pages

Over the last development session, we completed a full-stack implementation of the Sailor Board feature—a per-event photo gallery system that allows charter guests to upload photos and videos during and after their sail, with server-side Instagram hashtag aggregation, spam prevention via event codes, and a 24-file batch upload cap. This post covers the architecture, infrastructure decisions, and deployment pipeline.

What Was Built

The Sailor Board is not a global leaderboard or cross-charter aggregate. Instead, it's a per-event guest page that lives at /g/{event_id} and serves as both an upload portal and a gallery. When deployed for an event like Keely's 2026-05-24 afternoon charter, it appears at:

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

The page includes four core features:

  • Photo/video upload with JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM support
  • Event code validation to gate uploads and prevent spam
  • 24-file batch limit to prevent resource exhaustion
  • Same-day Instagram hashtag pull for #jada and #queenofsandiego posts

Frontend Architecture

The guest page is generated as a standalone HTML file and deployed to S3. The implementation lives in:

/Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html

This file is served directly from S3 via CloudFront (staging distribution alias: d111111.cloudfront.net, invalidated after each deploy). The page is a single-file SPA with embedded JavaScript and CSS—no external dependencies beyond the AWS SDK for signed uploads.

Key form elements:

  • #file-input (line 362): Multi-file input accepting JPEG/PNG/WebP/HEIC/HEIF + video formats
  • #g-code (line 354): Event code input for spam gating
  • #photo-grid (lines 452–459): Gallery render zone for uploaded photos and Instagram posts
  • #ig-grid: Dedicated zone for Instagram feed

Upload flow:

  1. User selects up to 24 files via #file-input
  2. handleFiles() (line 514) slices array to first 24: Array.from(files).slice(0, 24).forEach(uploadFile)
  3. For each file, uploadFile() calls the presigner endpoint: GET /api/presign?event_id={event_id}&filename={filename}&mime={mime}
  4. Lambda returns a signed S3 POST URL
  5. Browser uploads directly to S3 bucket qos-photos-prod (or qos-photos-staging for testing)
  6. S3 object creation triggers a Lambda that writes metadata to DynamoDB table qos_photos_prod and generates thumbnails

Event code gating:

The readCode() function (line 424) checks if a code is provided. If present, the upload bypasses the JADA review queue and goes straight to status: "published" in DynamoDB. If absent, uploads are flagged as status: "review" and a moderation email is sent to the event organizer. This is enforced server-side in the Lambda presigner and the S3 object handler.

Instagram integration:

The page renders Instagram posts into the gallery via data served from the Lambda endpoint:

GET /api/g/{event_id}/photos

The endpoint returns a JSON object with:

{ "photos": [...], "instagram": [...] }

The d.instagram array is populated by the Lambda's hashtag fetcher, which queries Instagram's public API (via a custom IG App credential) for posts matching #jada and #queenofsandiego posted on the event date. This is wired into tools/shipcaptaincrew/lambda_function.py, specifically the /api/g/{event_id}/photos handler.

Backend: Lambda and S3 Orchestration

Presigner endpoint:

The Lambda function at tools/shipcaptaincrew/lambda_function.py exposes:

GET /api/presign?event_id=2026-05-24-keely-afternoon&filename=photo.jpg&mime=image/jpeg

This endpoint:

  • Validates the event ID against the qos_events DynamoDB table
  • Generates a signed S3 POST form with a 15-minute TTL
  • Returns the POST URL and form fields so the browser can upload directly to S3
  • Sets the S3 object key to a deterministic pattern: g/{event_id}/{uuid4()}/{filename}

Photo metadata handler (S3 event):

When an object lands in qos-photos-prod, an S3 event triggers a Lambda function that:

  1. Extracts the event_id and filename from the object key
  2. Reads the object metadata (EXIF, size, MIME type)
  3. Validates the event code from a custom header or query param (if provided during presign)
  4. Writes a record to DynamoDB qos_photos_prod with status: "published" or "review"
  5. Generates thumbnails (320px, 640px) via PIL/Pillow and stores them in the same S3 key prefix
  6. If status is "review", sends a moderation email to the event organizer with a magic link to approve/reject

Thumbnail generation:

Thumbnails are generated synchronously in the Lambda (no SQS queue). The Lambda runtime is Python 3.11 with Pillow 10.x bundled in the deployment zip. After a failed initial deploy, we ran a backfill script (/tmp/backfill-thumbs.py) to retroactively generate thumbs for existing photos. This was necessary because early uploads predated the thumbnail logic.

Infrastructure and Deployment

S3 buckets:

  • qos-photos-prod: Production photo uploads (object lifecycle: 90 days retention, then delete)
  • qos-photos-staging