```html

Building the Sailor Board: Guest Photo Upload, Moderation, and Same-Day Instagram Integration

This post documents the design and deployment of Queen of San Diego's per-event guest photo gallery system—the "Sailor Board"—including photo upload with spam gating, server-side moderation, thumbnail generation, and real-time Instagram hashtag indexing.

What Was Built

The Sailor Board is a per-event photo gallery deployed to each guest page (e.g., /g/2026-05-24-keely-afternoon) that allows guests to:

  • Upload photos and videos with an event-specific code to bypass moderation
  • Upload up to 24 files at once (JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM)
  • See their own uploads instantly if they provide the code; otherwise, uploads go to a review queue
  • View Instagram posts from the same day tagged with #jada or #queenofsandiego
  • See a real-time gallery of approved guest photos

The system is live at queenofsandiego.com/g/{event_date}-{guest_name} and fully functional for post-event photo collection.

Architecture Overview

The Sailor Board spans three layers:

  • Client: Guest page HTML + vanilla JS (file upload, moderation UI, IG feed rendering)
  • Compute: AWS Lambda function at tools/shipcaptaincrew/lambda_function.py (presigned URL generation, photo metadata, IG hashtag search, moderation)
  • Storage: S3 bucket for raw uploads, DynamoDB for photo metadata and moderation state, CloudFront for cached photo delivery

File Upload Flow

The guest page (/g/2026-05-24-keely-afternoon) contains a file input (line 362) that triggers handleFiles() (line 514). This function:

  1. Reads the guest's event code from #g-code input (line 354) if provided
  2. Slices the file array to first 24 files: Array.from(files).slice(0, 24).forEach(uploadFile) (line 515)
  3. For each file, calls POST /presign to the shipcaptaincrew Lambda endpoint
  4. Receives a presigned S3 URL valid for 15 minutes
  5. Uploads directly to S3 from the browser, bypassing the Lambda (reduces compute cost, improves latency)

The presign endpoint (lambda_function.py, handle_presign() function) validates:

  • Event ID matches the requested charter date
  • File MIME type is in the allowed list
  • File size is under 100 MB
  • Returns a presigned URL to s3://qos-guest-photos/{event_id}/{uuid}.{ext}

Spam Gating: The Event Code

Without an event code, uploads go straight to DynamoDB with moderation_status = "PENDING" (Lambda, line 350). The guest page displays a note: "The code keeps strangers from posting to your charter page." With a valid code, the Lambda sets moderation_status = "APPROVED" immediately, and the photo appears in the gallery within seconds.

The code is per-event and shared with all guests invited to that charter. It's embedded in the email invitation (see proposals/keely-email-body.html) but not visible in the guest page itself—guests must copy it from the invitation.

Why this design? Public URLs to guest pages should exist (for social sharing), but photo uploads should require proof of attendance. A simple code is low-friction and doesn't require authentication infrastructure.

Thumbnail Generation and Storage

When a photo is uploaded, an S3 event triggers a Lambda that:

  1. Downloads the raw image from qos-guest-photos/
  2. Resizes it to thumbnail dimensions (e.g., 300×300 for gallery grid)
  3. Uploads the thumbnail to a separate S3 path: qos-guest-photos-thumbs/{event_id}/{uuid}.jpg
  4. Stores the dimensions and file size in DynamoDB

This is done with Python Pillow in the Lambda runtime. For large backfills (e.g., converting existing raw photos to thumbnails), a standalone script (/tmp/backfill-thumbs.py) can be run locally to batch-process photos:

python backfill-thumbs.py --event-id 2026-05-24 --bucket qos-guest-photos --thumbs-bucket qos-guest-photos-thumbs

Why separate buckets? Keeps raw originals immutable and makes it trivial to invalidate or regenerate thumbnails without touching source data. Thumbnails are also served with a shorter CloudFront TTL for faster updates.

Instagram Hashtag Integration

The guest page makes a request to GET /api/g/{event_id}/photos which returns a JSON object with two keys:

  • d.photos — guest uploads, filtered by moderation status
  • d.instagram — posts from the same day tagged #jada or #queenofsandiego

The Lambda handler fetches Instagram data via a third-party API (exact integration redacted for security) and caches results in DynamoDB with a 1-hour TTL. The client renders both lists in #photo-grid (lines 452–459) as a merged timeline.

Why hashtag search instead of account tagging? Hashtags are public, discoverable, and don't require guests to know the official account handle. Searching #jada (the boat name) and #queenofsandiego catches posts from guests who may have used either tag.

Moderation Workflow

Photos uploaded without a code trigger an email to the charter captain at lambda_function.py:2066:

Subject: New photo upload from guests on {charter_date}
Body: {photo_count} photos pending review
[View sailor board link → /g/{event_id}]
[Approve/Reject buttons in email]

The captain clicks a link in the email to approve or reject. This updates DynamoDB (moderation_status = "APPROVED" | "REJECTED") and the gallery updates on next refresh.

Infrastructure and Deployment

S3 Buckets:

  • qos-guest-photos — raw uploads (private, versioned, lifecycle policy to delete after 90 days)
  • qos-guest-photos-thumbs — thumbnails (public