feat: share post to other apps

This commit is contained in:
gosangam
2026-05-31 08:44:29 +05:30
parent 88f1b08675
commit 5506afa758
5 changed files with 276 additions and 36 deletions
+78
View File
@@ -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 <img> 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/<id>
* 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<PostShape>(`/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 });
}
}
+17 -14
View File
@@ -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({
<AuthGateProvider>
<AudioPlayerProvider>
<TooltipProvider>
<VerifyEmailBanner />
<SiteSidebar />
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
<SiteHeader />
{children}
</div>
<SiteBottomNav />
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<PageBookmarks />
<Toaster />
<SharePostProvider>
<VerifyEmailBanner />
<SiteSidebar />
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
<SiteHeader />
{children}
</div>
<SiteBottomNav />
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<PageBookmarks />
<Toaster />
</SharePostProvider>
</TooltipProvider>
</AudioPlayerProvider>
</AuthGateProvider>