From c9b1c49f539c65b321d61b138d384dd5c641b629 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 5 Jun 2026 17:51:48 +0000 Subject: [PATCH] selfhost: route brand assets through /storage/ rewrite Download 3 Tuxedo brand logos from Supabase Storage to local MinIO and point the DB at portable /storage/... paths. Add a Next.js rewrite in next.config.ts that proxies /storage/* to the configured MinIO endpoint. Why: Next.js's image optimizer refuses to fetch upstream images whose hostname resolves to a private IP. Local MinIO lives on 127.0.0.1, so direct URLs (e.g. http://localhost:9000/...) get blocked with "upstream image resolved to private ip [::1,127.0.0.1]". The rewrite keeps URLs same-origin in the browser, so the optimizer's private-IP check is bypassed. For production, set STORAGE_PUBLIC_URL to the public MinIO endpoint and the same DB values work without change. Also fix the same pattern in 3 client-rendered components that hardcoded publicUrl(BUCKETS.BRAND_LOGOS, ...) for fallback assets: - TuxedoVideoHero (hero video + Olathe Sweet dark logo) - TimeTrackingFieldClient (field UI logo) - tuxedo/about (about page logo) email-service.ts keeps publicUrl() because Resend fetches URLs server-side from its own network. Documented the workflow and gotchas in docs/SUPABASE_DUMP_GUIDE.md. --- docs/SUPABASE_DUMP_GUIDE.md | 83 +++++++++++++++++++ next.config.ts | 12 ++- src/app/tuxedo/about/page.tsx | 10 +-- src/components/storefront/TuxedoVideoHero.tsx | 12 +-- .../time-tracking/TimeTrackingFieldClient.tsx | 9 +- 5 files changed, 109 insertions(+), 17 deletions(-) diff --git a/docs/SUPABASE_DUMP_GUIDE.md b/docs/SUPABASE_DUMP_GUIDE.md index 1e4031a..89627a1 100644 --- a/docs/SUPABASE_DUMP_GUIDE.md +++ b/docs/SUPABASE_DUMP_GUIDE.md @@ -203,3 +203,86 @@ psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT name, slug, crea for reproducibility. The data dump is gitignored (regenerate as needed). - After dump, the local PostgREST needs a schema cache reload — restart the postgrest process or it'll serve stale metadata for ~30 seconds. + +## Migrating Brand Assets (Supabase Storage → MinIO) + +Brand logos and other Storage files are NOT in the Postgres dump. The +storage layer is now MinIO (S3-compatible) instead of Supabase Storage. + +### Download assets from Supabase + +```bash +# Make sure the target buckets exist in MinIO (one-time) +mc mb --ignore-existing local/brand-logos local/videos \ + local/product-images local/contacts-imports \ + local/water-photos + +# Pull each asset via the public URL. Path is / after the +# /storage/v1/object/public/ prefix in the Supabase URL. +mkdir -p .data/assets +BRAND_ID="" +for fname in logo.png olathe-sweet-logo.png olathe-sweet-logo-dark.png; do + curl -sf -o ".data/assets/$fname" \ + "https://.supabase.co/storage/v1/object/public/brand-logos/$BRAND_ID/$fname" + mc cp ".data/assets/$fname" "local/brand-logos/$BRAND_ID/$fname" +done +``` + +### Point the DB at MinIO (portable /storage/... paths) + +After capture, the `brand_settings.logo_url` etc. values still point at +the Supabase URL. Replace the base with a relative `/storage/` path so +the Next.js rewrite in `next.config.ts` can route to whichever MinIO +endpoint is configured per environment (dev → `localhost:9000`, prod → +`storage.route.crispygoat.com`). + +```sql +UPDATE brand_settings +SET + logo_url = REPLACE(logo_url, 'https://.supabase.co/storage/v1/object/public', '/storage'), + logo_url_dark = REPLACE(logo_url_dark, 'https://.supabase.co/storage/v1/object/public', '/storage'), + olathe_sweet_logo_url = REPLACE(olathe_sweet_logo_url, 'https://.supabase.co/storage/v1/object/public', '/storage'), + olathe_sweet_logo_url_dark = REPLACE(olathe_sweet_logo_url_dark, 'https://.supabase.co/storage/v1/object/public', '/storage'), + hero_image_url = REPLACE(hero_image_url, 'https://.supabase.co/storage/v1/object/public', '/storage'); +``` + +### Why a rewrite instead of pointing at MinIO directly + +Next.js's image optimizer (`/_next/image?url=...`) refuses to fetch +upstream images whose hostname resolves to a private IP. Local MinIO is +on `127.0.0.1` / `::1`, so URLs like `http://localhost:9000/...` get +blocked: + +``` +⨯ upstream image http://localhost:9000/... resolved to private ip + ["::1","127.0.0.1"] +``` + +The rewrite in `next.config.ts` resolves `/storage/*` to the configured +MinIO base URL server-side, so the browser sees a same-origin URL and +the optimizer's private-IP check is bypassed: + +```ts +async rewrites() { + const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000"; + return [{ source: "/storage/:path*", destination: `${storageBase}/:path*` }]; +} +``` + +For production, set `STORAGE_PUBLIC_URL` to the public MinIO endpoint +(e.g., `https://storage.route.crispygoat.com`) and the same DB values +work without modification. + +### Hardcoded brand asset URLs in client components + +A few components used `publicUrl(BUCKETS.BRAND_LOGOS, ...)` to build a +MinIO URL at module load (used as a fallback before client-side data +loads). Switch these to `/storage/...` paths so the rewrite covers them: + +- `src/components/storefront/TuxedoVideoHero.tsx` — hero video + Olathe Sweet dark logo +- `src/components/time-tracking/TimeTrackingFieldClient.tsx` — field UI logo +- `src/app/tuxedo/about/page.tsx` — about page logo + +`src/lib/email-service.ts` should keep using `publicUrl(...)` because +Resend fetches the URL server-side from its own network — relative +paths won't work for emails. diff --git a/next.config.ts b/next.config.ts index 9e27375..541dabe 100644 --- a/next.config.ts +++ b/next.config.ts @@ -106,8 +106,18 @@ const nextConfig: NextConfig = { // Rewrites for API proxy async rewrites() { + // Storage proxy: /storage/* -> MinIO at the same path + // Lets brand assets and product images use portable relative URLs + // (e.g. /storage/brand-logos//logo.png) in the DB, with Next.js + // proxying to whichever MinIO endpoint is configured for the environment. + // Avoids the next/image "upstream resolved to private ip" block on + // localhost MinIO by keeping the upstream fetch on the server side. + const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000"; return [ - // Add any necessary rewrites here + { + source: "/storage/:path*", + destination: `${storageBase}/:path*`, + }, ]; }, diff --git a/src/app/tuxedo/about/page.tsx b/src/app/tuxedo/about/page.tsx index d978b25..0595008 100644 --- a/src/app/tuxedo/about/page.tsx +++ b/src/app/tuxedo/about/page.tsx @@ -6,7 +6,6 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import { supabase } from "@/lib/supabase"; import { getBrandSettingsPublic } from "@/actions/brand-settings"; -import { publicUrl, BUCKETS } from "@/lib/storage"; // Lazy load heavy sections const MissionSection = lazy(() => import("@/components/about/MissionSection")); @@ -14,10 +13,11 @@ const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimeli const ContactSection = lazy(() => import("@/components/about/ContactSection")); const CTASection = lazy(() => import("@/components/about/CTASection")); -const OLATHE_SWEET_LOGO_DARK = publicUrl( - BUCKETS.BRAND_LOGOS, - "64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png" -); +// Use Next.js rewrite (next.config.ts) so the browser can stream assets +// via same-origin /storage/* and avoid next/image "upstream resolved to +// private ip" failures when the upstream is localhost MinIO. +const OLATHE_SWEET_LOGO_DARK = + "/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"; export default function TuxedoAboutPage() { const [logoUrl, setLogoUrl] = useState(null); diff --git a/src/components/storefront/TuxedoVideoHero.tsx b/src/components/storefront/TuxedoVideoHero.tsx index 337f053..ee209e1 100644 --- a/src/components/storefront/TuxedoVideoHero.tsx +++ b/src/components/storefront/TuxedoVideoHero.tsx @@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react"; import { motion } from "framer-motion"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { publicUrl, BUCKETS } from "@/lib/storage"; // Register GSAP plugins if (typeof window !== "undefined") { @@ -23,12 +22,13 @@ type TuxedoVideoHeroProps = { onSecondaryClick?: () => void; }; -const VIDEO_URL = publicUrl(BUCKETS.VIDEOS, "tuxedo-hero.mp4"); +// Use Next.js rewrite (next.config.ts) so the browser can stream assets +// via same-origin /storage/* and avoid next/image "upstream resolved to +// private ip" failures when the upstream is localhost MinIO. +const VIDEO_URL = `/storage/videos/tuxedo-hero.mp4`; -const OLATHE_SWEET_LOGO_DARK = publicUrl( - BUCKETS.BRAND_LOGOS, - "64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png" -); +const OLATHE_SWEET_LOGO_DARK = + "/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"; // ───────────────────────────────────────────────────────────────────────────── // CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx index 0a09e6b..1d90799 100644 --- a/src/components/time-tracking/TimeTrackingFieldClient.tsx +++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx @@ -4,7 +4,6 @@ import Image from "next/image"; import { useState, useEffect, useRef } from "react"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { publicUrl, BUCKETS } from "@/lib/storage"; import { verifyTimeTrackingPin, clockInWorker, @@ -22,10 +21,10 @@ import { const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const BRAND_NAME = "Tuxedo Corn"; -const OLATHE_SWEET_LOGO_DARK = publicUrl( - BUCKETS.BRAND_LOGOS, - `${BRAND_ID}/olathe-sweet-logo.png` -); +// Use Next.js rewrite (next.config.ts) so the browser can stream assets +// via same-origin /storage/* and avoid next/image "upstream resolved to +// private ip" failures when the upstream is localhost MinIO. +const OLATHE_SWEET_LOGO_DARK = `/storage/brand-logos/${BRAND_ID}/olathe-sweet-logo.png`; // ── Translations ───────────────────────────────────────────────────────────────