diff --git a/app/api/share-image/[id]/route.ts b/app/api/share-image/[id]/route.ts new file mode 100644 index 00000000..18ed85b6 --- /dev/null +++ b/app/api/share-image/[id]/route.ts @@ -0,0 +1,78 @@ +/** + * Same-origin proxy for a post's first attached image. + * + * Why this exists: + * + * navigator.share({ files }) needs a Blob built from a successful + * cross-origin fetch. The R2 bucket sits behind a custom domain + * (`s3.prabhupadasays.com`), so even with a correct CORS policy on + * the bucket, the browser will reuse pre-CORS cached responses from + * when the image was first loaded by an tag without a `Vary: + * Origin` header. The cached copy lacks `Access-Control-Allow-Origin`, + * so subsequent CORS fetches from the share flow blow up with a CORS + * error even though R2 itself is now configured correctly. + * + * Routing the fetch through this same-origin endpoint sidesteps the + * whole CORS layer. The browser's request to /api/share-image/ + * is same-origin and never carries an Origin header to R2; Next.js + * fetches the image server-side (no CORS at all) and pipes the + * bytes back to the browser. + * + * Security: we fetch by post id (not by raw URL) so the route can't + * be coerced into SSRF — only images attached to a real post are + * reachable, and the post lookup goes through the BFF. + */ + +import { bff } from "@/lib/bff"; + +export const runtime = "nodejs"; + +type PostShape = { + id: string; + images: { url: string }[]; +}; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await context.params; + const post = await bff(`/posts/${encodeURIComponent(id)}`, { + revalidate: 60, + }); + if (!post || !post.images?.[0]?.url) { + return new Response("Image not found", { status: 404 }); + } + const imageUrl = post.images[0].url; + + // Server-side fetch — no CORS at all from this side of the wire. + const upstream = await fetch(imageUrl); + if (!upstream.ok || !upstream.body) { + return new Response("Upstream fetch failed", { + status: upstream.status || 502, + }); + } + + const contentType = + upstream.headers.get("content-type") || "image/jpeg"; + const contentLength = upstream.headers.get("content-length"); + + return new Response(upstream.body, { + status: 200, + headers: { + "Content-Type": contentType, + // Allow shared CDN caching for a day — image content for a + // given post id is immutable in practice (replacing the image + // mints a new key), so a long TTL is safe. Browser cache + // shorter so updates are picked up faster on the share flow. + "Cache-Control": + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", + ...(contentLength ? { "Content-Length": contentLength } : {}), + }, + }); + } catch (e) { + console.error("share-image proxy failed:", e); + return new Response("Proxy error", { status: 500 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 14484ca3..ec0dcc89 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,6 +15,7 @@ import { AuthProvider } from "@/components/auth/auth-provider"; import { AuthGateProvider } from "@/components/auth/auth-gate"; import { VerifyEmailBanner } from "@/components/auth/verify-email-banner"; import { PageBookmarks } from "@/components/social/page-bookmarks"; +import { SharePostProvider } from "@/components/social/share-post-provider"; import { Toaster } from "@/components/ui/toaster"; import "./globals.css"; @@ -77,20 +78,22 @@ export default function RootLayout({ - - -
- - {children} -
- - - - - - - - + + + +
+ + {children} +
+ + + + + + + + +
diff --git a/components/selection-toolbar/compose-post.tsx b/components/selection-toolbar/compose-post.tsx index 6481b18e..88874017 100644 --- a/components/selection-toolbar/compose-post.tsx +++ b/components/selection-toolbar/compose-post.tsx @@ -17,6 +17,7 @@ import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { X, ImagePlus, Trash2, Send } from "lucide-react"; +import { toast } from "sonner"; import { Drawer, DrawerContent, @@ -26,6 +27,7 @@ import { import { posts, SocialError } from "@/lib/social"; import { buildHighlightUrl } from "@/lib/text-highlight"; import { getSourceReference } from "@/lib/source-reference"; +import { useSharePost } from "@/components/social/share-post-provider"; const MAX_IMAGES = 5; const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif"; @@ -44,6 +46,7 @@ export function ComposePost({ onPosted?: () => void; }) { const router = useRouter(); + const { openShareSheet } = useSharePost(); const fileInput = useRef(null); const [images, setImages] = useState<{ url: string; uploading: boolean }[]>( [], @@ -108,7 +111,7 @@ export function ComposePost({ : typeof document !== "undefined" ? document.title : ""; - await posts.create({ + const newPost = await posts.create({ caption: selectedText, source_url: sourceURL, source_title: sourceTitle, @@ -119,6 +122,19 @@ export function ComposePost({ onPosted?.(); onClose(); router.refresh(); + // Success feedback + offer to externally share. The BFF's Create + // response doesn't include author info (no JOIN with users), but + // SharePostSheet falls back to the authed user's handle when + // post.author_handle is missing — perfect for a freshly-created + // post since the creator IS the auth user. + toast.success("Posted to your feed", { + description: "Want to share it to other apps?", + action: { + label: "Share", + onClick: () => openShareSheet(newPost), + }, + duration: 5000, + }); } catch (err) { if (err instanceof SocialError && err.status === 401) { setError("Please log in to post."); diff --git a/components/social/share-post-provider.tsx b/components/social/share-post-provider.tsx new file mode 100644 index 00000000..12c0519a --- /dev/null +++ b/components/social/share-post-provider.tsx @@ -0,0 +1,69 @@ +"use client"; + +/** + * SharePostProvider — mounts a single global SharePostSheet that any + * component in the tree can summon via `useSharePost().openShareSheet(post)`. + * + * The sheet itself is a heavy modal/drawer that loads URLs, pre-fetches + * images for native share, etc. — we don't want every caller to mount + * its own copy. Mounting once at the root and triggering via context + * keeps state isolated and makes "Share" a one-liner anywhere downstream: + * + * const { openShareSheet } = useSharePost(); + * openShareSheet(newlyCreatedPost); + * + * Used today by compose-post.tsx (toast after a successful post create + * offers a "Share" action that opens the sheet for the new post). + * Future surfaces like profile cards can call into the same hook. + */ + +import { + createContext, + useCallback, + useContext, + useState, +} from "react"; +import type { Post } from "@/lib/social"; +import { useMediaQuery } from "@/components/selection-toolbar/shared"; +import { SharePostSheet } from "./share-post-sheet"; + +type SharePostContextValue = { + openShareSheet: (post: Post) => void; +}; + +const SharePostContext = createContext(null); + +export function useSharePost(): SharePostContextValue { + const v = useContext(SharePostContext); + if (!v) { + throw new Error("useSharePost must be used inside "); + } + return v; +} + +export function SharePostProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [activePost, setActivePost] = useState(null); + const isMobile = useMediaQuery("(max-width: 767px)"); + + const openShareSheet = useCallback((post: Post) => { + setActivePost(post); + }, []); + + return ( + + {children} + {activePost ? ( + setActivePost(null)} + post={activePost} + isMobile={isMobile} + /> + ) : null} + + ); +} diff --git a/components/social/share-post-sheet.tsx b/components/social/share-post-sheet.tsx index ececacb9..03a2e86c 100644 --- a/components/social/share-post-sheet.tsx +++ b/components/social/share-post-sheet.tsx @@ -65,7 +65,10 @@ import { import { cn } from "@/lib/utils"; import type { Post } from "@/lib/social"; import { createShortLink } from "@/lib/share"; -import { displaySourceLabel } from "@/lib/source-reference"; +import { + displaySourceLabel, + referenceFromUrl, +} from "@/lib/source-reference"; import { useAuth } from "@/components/auth/auth-provider"; export function SharePostSheet({ @@ -79,7 +82,48 @@ export function SharePostSheet({ post: Post; isMobile: boolean; }) { + // `reference` is the single-line citation used for UI chrome — the + // drawer header, email subject, etc. where multi-line doesn't render. const reference = displaySourceLabel(post.source_url, post.source_title); + // `structuredRef` is the same citation split into its `{ title, detail }` + // halves so the share text can lay them on separate lines — matches + // the formatCopyText pattern from selection-toolbar/shared.tsx: + // + // "caption text" + // + // Source: + // Letter to Mukunda + // 15 January, 1968 · Calcutta + // short URL + // + // Two routes to the structured shape: + // + // 1. Book URLs → `referenceFromUrl` parses the path and returns + // a clean { title, detail } (e.g. Bhagavad-gītā / + // Chapter 2, Verse 13). + // 2. Everything else (letters, essays, transcriptions, magazines) + // → `source_title` was composed by compose-post.tsx + // as "Title — Detail" using ` — ` as the join. + // We split on the first ` — ` to recover the + // structured fields. Falls back to title-only if + // no separator is present (book-index pages, + // legacy posts, etc.). + const structuredRef = useMemo<{ title: string; detail: string | null }>( + () => { + const r = referenceFromUrl(post.source_url); + if (r) return r; + const stored = post.source_title?.trim() || "Source"; + const idx = stored.indexOf(" — "); + if (idx === -1) { + return { title: stored, detail: null }; + } + return { + title: stored.slice(0, idx).trim(), + detail: stored.slice(idx + 3).trim() || null, + }; + }, + [post.source_url, post.source_title], + ); const firstImage = post.images?.[0]?.url ?? null; const { user } = useAuth(); // The Send button is only rendered for owners, so user is guaranteed @@ -161,8 +205,16 @@ export function SharePostSheet({ // fetch INSIDE the click handler burns the activation window and // iOS rejects the share with NotAllowedError. // - // Failures (CORS-blocked fetch, 4xx, network error) are remembered - // in imageFetchError so the click handler can toast a useful + // We hit our same-origin proxy (/api/share-image/) rather than + // the raw R2 URL. Reason: even with R2's CORS policy set correctly, + // browsers that have a pre-CORS response cached for the image (from + // earlier loads without a `Vary: Origin` header) will serve + // that stale no-ACAO copy and the fetch fails CORS regardless of + // how the bucket is configured. The proxy is same-origin so the + // browser never sends an Origin header to R2 in the first place. + // + // Failures (4xx from the proxy, network error) are remembered in + // imageFetchError so the click handler can toast a useful // explanation. We don't toast at fetch time because the user hasn't // committed to sharing yet — that would feel like noise. useEffect(() => { @@ -174,15 +226,16 @@ export function SharePostSheet({ let cancelled = false; setShareableFile(null); setImageFetchError(null); + const proxyUrl = `/api/share-image/${encodeURIComponent(post.id)}`; (async () => { try { - const res = await fetch(firstImage, { mode: "cors" }); + const res = await fetch(proxyUrl); if (cancelled) return; if (!res.ok) { setImageFetchError(`HTTP ${res.status}`); console.warn( - `[share] image pre-fetch returned ${res.status} for`, - firstImage, + `[share] image proxy returned ${res.status} for post`, + post.id, ); return; } @@ -195,13 +248,10 @@ export function SharePostSheet({ setShareableFile(file); } catch (err) { if (cancelled) return; - setImageFetchError("cors"); - // Almost always a CORS preflight failure when the public R2 - // domain is a separate hostname. Log so it's visible in - // DevTools when the user reports "image didn't attach". + setImageFetchError("network"); console.warn( - "[share] image pre-fetch failed (likely CORS) for", - firstImage, + "[share] image proxy fetch failed for post", + post.id, err, ); } @@ -209,14 +259,28 @@ export function SharePostSheet({ return () => { cancelled = true; }; - }, [open, firstImage]); + }, [open, firstImage, post.id]); - // Canonical share payload — quote, reference, link. Built lazily because - // shortUrl arrives async; while loading we render a spinner instead. + // Canonical share payload — quote, "Source:" delimiter, structured + // citation, link. Built lazily because shortUrl arrives async; while + // loading we render a spinner instead. The structure mirrors + // formatCopyText from selection-toolbar/shared.tsx exactly so the + // Copy and Share flows produce identical text: + // + // "caption" + // + // Source: + // Book Name + // Chapter, Verse + // short URL const shareText = useMemo(() => { if (!shortUrl) return ""; - return `“${post.caption}”\n\n— ${reference}\n\n${shortUrl}`; - }, [post.caption, reference, shortUrl]); + const lines: string[] = [`“${post.caption}”`, "", "Source:"]; + lines.push(structuredRef.title); + if (structuredRef.detail) lines.push(structuredRef.detail); + lines.push(shortUrl); + return lines.join("\n"); + }, [post.caption, structuredRef, shortUrl]); function openIntent(url: string) { window.open(url, "_blank", "noopener,noreferrer"); @@ -258,10 +322,10 @@ export function SharePostSheet({ // (or failed). Surface the reason so the user knows why the // share is text-only — at the moment they actually share, not // earlier when the sheet first opened. - if (imageFetchError === "cors") { + if (imageFetchError === "network") { toast( - "Couldn't load image (CORS) — sharing text only. Check R2 CORS policy.", - { duration: 4000 }, + "Couldn't load image for sharing — sharing text only", + { duration: 3000 }, ); } else if (imageFetchError) { toast(`Image not available (${imageFetchError}) — sharing text only`, { @@ -330,9 +394,19 @@ export function SharePostSheet({ onClick: () => { // Telegram's share URL splits text + url so the link is // attached as a separate entity (better preview rendering). + // Keep the same multi-line citation — including the "Source:" + // delimiter — as the canonical share text. Telegram preserves + // newlines in the message body so the structure carries over. + const lines = [ + `“${post.caption}”`, + "", + "Source:", + structuredRef.title, + ]; + if (structuredRef.detail) lines.push(structuredRef.detail); const params = new URLSearchParams({ url: shortUrl ?? "", - text: `“${post.caption}” — ${reference}`, + text: lines.join("\n"), }); openIntent(`https://t.me/share/url?${params.toString()}`); },