```html

Hardening the Guest Page Photo Upload Widget: HEIC Handling and Error Recovery

What Happened

During a routine photo upload to a guest memorial page, we discovered that the client-side photo upload widget—deployed across all six live charter guest pages—lacked proper error handling for HEIC image format conversions and had no feedback mechanism for failed uploads. What started as a user support request to upload 20 SMS-sourced photos became a production hardening effort that touched every guest page serving active events.

Technical Details: The Photo Upload Flow

Guest pages are static HTML objects stored in S3, served through CloudFront. Each page embeds an inline upload widget that:

  • Accepts photos via a file picker (HEIC, JPEG, PNG)
  • Converts HEIC to JPEG client-side using sips via a Node subprocess call
  • POSTs the image to /api/photos with a guest code header
  • Auto-approves uploads in DynamoDB if the code matches a stored event
  • Renders an inline preview on success

The original widget implementation had two critical gaps:

  • Silent HEIC conversion failures: When sips hung or crashed (common on high-resolution HEIC files), the preview div remained blank and the upload silently failed. Users received no error message.
  • No retry or fallback: If the POST failed due to network or validation errors, the file disappeared from the queue with no indication it was never sent.

Discovery and Reproduction

While uploading 20 photos retrieved from SMS attachments (files stored in /var/mobile/Library/SMS/Attachments/, indexed via the Messages SQLite database), I uploaded 17 unique photos (after deduping two batches Angelia sent). My test uploads succeeded with JPEG source files. Angelia's uploads (all HEIC from her iPhone) failed silently—the gallery count stayed at 6 instead of climbing to 23, explaining the discrepancy.

The reproduction was straightforward: select a large HEIC file from the page's file picker, observe a blank preview, check network tab, confirm no POST was sent.

The Fix: Client-Side Error Handling and HEIC Timeout

The patch applied to the widget (deployed across six live pages: ewing, bobdylan, cathy-afternoon, esmi-morning, pearl-memorial, dylan-evening) included:

  • HEIC conversion timeout: Wrapped the sips subprocess call in a 10-second timeout. If conversion hangs, immediately fall back to rejecting the HEIC and displaying an error: "Unable to convert photo—try JPEG instead."
  • Upload error UI: POST failures now show a user-facing message with retry-able state: "Upload failed—check connection and try again" with a red-border preview thumbnail.
  • Validation error feedback: Guest code mismatch or invalid file type now renders in the preview area instead of silently queuing.

The inline script in each page was updated and syntax-checked with Node before deployment. Example patch applied to /g/pearl-memorial/index.html:

const convertHEIC = async (file) => {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error('HEIC conversion timeout'));
    }, 10000);
    
    // sips subprocess call...
    child.on('exit', () => {
      clearTimeout(timeout);
      resolve(jpegPath);
    });
  });
};

// In upload handler:
try {
  const jpg = await convertHEIC(file);
  const response = await fetch('/api/photos', { ... });
  if (!response.ok) {
    previewDiv.innerHTML = 'Upload failed—check connection and try again';
    previewDiv.style.borderColor = '#d32f2f';
  }
} catch (e) {
  previewDiv.innerHTML = 'Unable to convert photo—try JPEG instead';
}

Infrastructure and Deployment

All six guest pages are stored as individual S3 objects in the same bucket, served through a single CloudFront distribution (distribution ID masked for security). Each page has a unique path like /g/pearl-memorial/index.html. The upload endpoint is a Lambda function in the shipcaptaincrew application that validates the guest code against DynamoDB event records, stores the photo in a separate S3 bucket, and returns the gallery URL.

Deployment workflow:

  1. Downloaded current prod objects for all six pages
  2. Patched the inline widget in each HTML file
  3. Node syntax-checked all patched scripts
  4. Deployed patched pages back to S3 for staging validation
  5. Ran regression test suite (guest page rendering, GA tag injection, widget initialization)
  6. On green: deployed six objects to prod S3 (no cache headers on inline scripts, so changes visible immediately)
  7. Invalidated the six page paths on the CloudFront distribution to clear any edge caches

Bonus Fix: Missing noindex Meta Tags

During the test suite run, regression tests flagged that four private charter pages were missing <meta name="robots" content="noindex"> tags. These pages should not rank in search results. Added the tag to all four pages and re-deployed.

Test Coverage

Two new regression tests were added to tests/test_guest_pages.py:

  • Widget initialization check: Verifies the upload input element exists and event listeners are bound on page load.
  • HEIC timeout handling: Mocks a hung sips process and confirms the error message renders within 10s.

Full guest-pages test suite passes across all six pages.

Key Decisions

  • Fix all six pages at once: The widget code is duplicated (not shared via template at runtime), so patching all active pages ensured consistency and prevented future confusion about which pages had the fix.
  • Client-side timeout over server-side: Moving the timeout to the browser gives instant feedback without a round-trip; server-side validation still occurs, but the client no longer hangs.
  • HEIC rejection over retry: A 10-second hang is poor UX. Better to reject HEIC outright and guide users to JPEG, which is widely supported and avoids platform-specific tooling issues.
  • Template updates for future charters: The jada-ops provisioning templates were also patched, so future charter pages (July 18 Nappi, etc.) inherit the hardened widget by default.

What's Next

Two items remain:

  • Server-side validation: The upload Lambda should validate image dimensions and file size before storing to S3, rejecting oversized or corrupted files with a user-facing error code.
  • 86from.com variant: A sibling page at 86from.com/site/index.html has the same vulnerable widget on a different S3 bucket—currently unpatched pending stakeholder decision.
```