Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/web/src/components/external-site-preview.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/tanstack-react";

import { ExternalSitePreview } from "./external-site-preview";

const meta = {
title: "Tools/ExternalSitePreview",
component: ExternalSitePreview,
args: {
url: "https://shieldcn.dev",
title: "Live preview of Shieldcn",
imageAlt: "Shieldcn Open Graph preview",
className: "h-[28rem] w-[48rem] border border-border",
},
} satisfies Meta<typeof ExternalSitePreview>;

export default meta;
type Story = StoryObj<typeof meta>;

export const WithImageFallback: Story = {
args: {
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%23171717'/%3E%3Ctext x='600' y='338' text-anchor='middle' dominant-baseline='middle' fill='%23fafafa' font-family='Arial' font-size='54'%3EOpen Graph preview%3C/text%3E%3C/svg%3E",
},
};

export const WithoutImageFallback: Story = {
args: { imageUrl: null },
};

export const Mobile: Story = {
args: {
className: "h-[30rem] w-[22rem] border border-border",
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%23171717'/%3E%3Ctext x='600' y='338' text-anchor='middle' dominant-baseline='middle' fill='%23fafafa' font-family='Arial' font-size='54'%3EOpen Graph preview%3C/text%3E%3C/svg%3E",
},
};
71 changes: 71 additions & 0 deletions apps/web/src/components/external-site-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { HiArrowTopRightOnSquare as External, HiGlobeAlt as Globe, HiPhoto as ImageIcon } from "react-icons/hi2";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export function ExternalSitePreview({
url,
title,
imageUrl,
imageAlt = "",
className,
}: {
url: string;
title: string;
imageUrl?: string | null;
imageAlt?: string;
className?: string;
}) {
const [mode, setMode] = useState<"live" | "image">("live");
const [sandbox, setSandbox] = useState<string | null>(null);

useEffect(() => {
try {
const crossOrigin = new URL(url).origin !== window.location.origin;
// Cross-origin sites may use their own storage and runtime while the
// browser's same-origin policy still prevents access to Modulora.
setSandbox(crossOrigin ? "allow-scripts allow-same-origin" : "allow-scripts");
} catch {
setSandbox("allow-scripts");
}
}, [url]);

return (
<div className={cn("relative overflow-hidden bg-secondary/20", className)}>
{mode === "live" ? (
sandbox ? (
<iframe
src={url}
title={title}
sandbox={sandbox}
referrerPolicy="no-referrer"
className="size-full bg-white"
/>
) : (
<div className="size-full bg-secondary/20" aria-hidden />
)
) : imageUrl ? (
<img src={imageUrl} alt={imageAlt} referrerPolicy="no-referrer" className="size-full object-cover" />
) : (
<div className="flex size-full flex-col items-center justify-center gap-2 p-8 text-center text-muted-foreground">
<ImageIcon className="size-6" />
<p className="text-xs">No Open Graph image was provided by this site.</p>
</div>
)}

<div role="group" aria-label="Preview mode" className="absolute left-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-white/15 bg-background/90 p-1 shadow-sm backdrop-blur-sm">
<Button type="button" size="sm" variant={mode === "live" ? "secondary" : "ghost"} aria-pressed={mode === "live"} className="h-7 gap-1.5 px-2 text-xs" onClick={() => setMode("live")}><Globe className="size-3.5" />Live site</Button>
<Button type="button" size="sm" variant={mode === "image" ? "secondary" : "ghost"} aria-pressed={mode === "image"} disabled={!imageUrl} className="h-7 gap-1.5 px-2 text-xs" onClick={() => setMode("image")}><ImageIcon className="size-3.5" />Image</Button>
</div>

<Button asChild size="icon-sm" variant="secondary" className="absolute right-3 top-3 z-10 shadow-sm">
<a href={url} target="_blank" rel="noreferrer" aria-label={`Open ${title} in a new tab`}><External /></a>
</Button>

<span className="pointer-events-none absolute bottom-3 left-3 z-10 rounded-md bg-background/90 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur-sm">
{mode === "live" ? "Cross-origin sandbox · forms and popups disabled" : "Open Graph image fallback"}
</span>
</div>
);
}
13 changes: 8 additions & 5 deletions apps/web/src/components/tool-listing-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router";
import { HiArrowTopRightOnSquare as External, HiCheckBadge as Check, HiGlobeAlt as Globe } from "react-icons/hi2";
import { ExternalSitePreview } from "@/components/external-site-preview";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { CatalogItem } from "@/data/catalog";
Expand All @@ -21,11 +22,13 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
</div>

<div className="overflow-hidden rounded-2xl border border-border/60 bg-card/40">
<div className="relative aspect-[16/9] min-h-[28rem] bg-secondary/20">
{site.ogImageUrl ? <img src={site.ogImageUrl} alt="" className="absolute inset-0 size-full object-cover opacity-30" /> : null}
<iframe src={site.url} title={`Live preview of ${item.title}`} sandbox="allow-scripts" referrerPolicy="no-referrer" className="relative size-full bg-white" />
<span className="absolute bottom-3 left-3 rounded-md bg-background/90 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur-sm">Isolated live preview · some sites may block embedding</span>
</div>
<ExternalSitePreview
url={site.url}
title={`Live preview of ${item.title}`}
imageUrl={site.ogImageUrl}
imageAlt={`Open Graph preview for ${item.title}`}
className="aspect-[16/9] min-h-[28rem]"
/>
</div>

<div className="mt-6 grid gap-4 md:grid-cols-2">
Expand Down
28 changes: 27 additions & 1 deletion apps/web/src/components/tool-listing-editor.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import type { Meta, StoryObj } from "@storybook/tanstack-react";

import { withRouter } from "../../.storybook/with-router";
import { ToolListingEditor } from "./tool-listing-editor";

const meta = { title: "Publishing/ToolListingEditor", component: ToolListingEditor, parameters: { layout: "padded" }, decorators: [(Story) => withRouter(Story)], args: { onInspect: async () => ({ ok: true, metadata: { canonicalUrl: "https://example.com", title: "Example design tool", description: "A focused tool for creating accessible interface palettes.", imageUrl: null } }), onSubmit: async () => ({ ok: true }), onSubmitted: async () => {}, onCreateDomain: async (domain) => ({ ok: true, record: { domain, token: "example-token", verified: false } }), onVerifyDomain: async () => ({ ok: true, verified: true }), onDiscoverDomainConnect: async () => ({ supported: false }) } } satisfies Meta<typeof ToolListingEditor>;
const previewImage = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%23171717'/%3E%3Ctext x='600' y='338' text-anchor='middle' dominant-baseline='middle' fill='%23fafafa' font-family='Arial' font-size='54'%3EShieldcn preview%3C/text%3E%3C/svg%3E";

const meta = {
title: "Publishing/ToolListingEditor",
component: ToolListingEditor,
parameters: { layout: "padded" },
decorators: [(Story) => withRouter(Story)],
args: {
onInspect: async () => ({
ok: true,
metadata: {
canonicalUrl: "https://shieldcn.dev",
title: "Shieldcn",
description: "Beautiful GitHub README badges and charts styled as shadcn/ui, plus a visual README builder.",
imageUrl: previewImage,
},
}),
onSubmit: async () => ({ ok: true }),
onSubmitted: async () => {},
onCreateDomain: async (domain) => ({ ok: true, record: { domain, token: "example-token", verified: false } }),
onVerifyDomain: async () => ({ ok: true, verified: true }),
onDiscoverDomainConnect: async () => ({ supported: false }),
},
} satisfies Meta<typeof ToolListingEditor>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
14 changes: 9 additions & 5 deletions apps/web/src/components/tool-listing-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef, useState, type FormEvent } from "react";
import { HiArrowTopRightOnSquare as External, HiGlobeAlt as Globe, HiArrowPath as Loader } from "react-icons/hi2";

import { ExternalSitePreview } from "@/components/external-site-preview";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { DnsRecordCard, OneClickSetup } from "@/components/domain-verify";
Expand Down Expand Up @@ -123,13 +124,16 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo
<div className="min-h-[34rem] overflow-hidden rounded-xl border border-border/60 bg-card/30">
{preview ? (
<div className="flex h-full flex-col">
<div className="relative aspect-[16/10] min-h-80 border-b border-border/60 bg-secondary/20">
<iframe src={preview.canonicalUrl} title={`Live preview of ${preview.title || title}`} sandbox="allow-scripts" referrerPolicy="no-referrer" className="size-full bg-white" />
{preview.imageUrl ? <img src={preview.imageUrl} alt="" referrerPolicy="no-referrer" className="pointer-events-none absolute bottom-3 right-3 h-20 w-32 rounded-md border border-white/20 object-cover shadow-lg" /> : null}
</div>
<ExternalSitePreview
url={preview.canonicalUrl}
title={`Live preview of ${preview.title || title}`}
imageUrl={preview.imageUrl}
imageAlt={`Open Graph preview for ${preview.title || title}`}
className="aspect-[16/10] min-h-80 border-b border-border/60"
/>
<div className="flex flex-1 flex-col gap-2 p-5">
<div className="flex items-start justify-between gap-4"><div><p className="text-lg font-semibold">{title || preview.title}</p><p className="text-sm text-muted-foreground">{description || preview.description}</p></div><External className="mt-1 size-4 shrink-0 text-muted-foreground" /></div>
<p className="mt-auto text-xs text-muted-foreground">The iframe uses an opaque sandbox without same-origin access. Sites that block framing still retain their mirrored Open Graph image.</p>
<p className="mt-auto text-xs text-muted-foreground">The live preview keeps forms and popups disabled. If a site blocks framing or cannot run embedded, switch to its Open Graph image or open it in a new tab.</p>
</div>
</div>
) : <div className="flex h-full min-h-[34rem] flex-col items-center justify-center gap-3 p-8 text-center text-muted-foreground"><Globe className="size-8" /><p className="text-sm font-medium text-foreground">Inspect a verified site</p><p className="max-w-sm text-xs">Modulora will fetch its Open Graph metadata, mirror the preview image, and prepare the isolated live preview.</p></div>}
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/lib/manual-redirect-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export async function fetchWithManualRedirects({
inputUrl,
validateUrl,
fetcher = fetch,
init = {},
maximumRedirects = 3,
}: {
inputUrl: URL;
validateUrl: (url: URL) => Promise<boolean> | boolean;
fetcher?: typeof fetch;
init?: RequestInit;
maximumRedirects?: number;
}): Promise<{ url: URL; response: Response } | null> {
let current = inputUrl;
for (let redirect = 0; redirect <= maximumRedirects; redirect += 1) {
if (!(await validateUrl(current))) return null;
const response = await fetcher(current, { ...init, redirect: "manual" });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) return null;
await response.body?.cancel();
current = new URL(location, current);
continue;
}
return { url: current, response };
}
return null;
}
43 changes: 31 additions & 12 deletions apps/web/src/lib/tool-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { normalizeDomain } from "./domains";
import { storeImage } from "./media";
import { fetchWithManualRedirects } from "./manual-redirect-fetch";
import { extractToolMetadata, isPrivateOrReservedIp, type ToolMetadata } from "./tool-metadata-core";

const MAX_HTML_BYTES = 512 * 1024;
Expand Down Expand Up @@ -84,20 +85,38 @@ async function fetchVerifiedPage(inputUrl: string, verifiedDomain: string): Prom
throw new Error("The site redirected too many times.");
}

async function fetchPublicImage(inputUrl: URL): Promise<Response | null> {
const result = await fetchWithManualRedirects({
inputUrl,
validateUrl: async (url) => {
if (url.protocol !== "https:") return false;
await assertPublicHostname(url.hostname);
return true;
},
init: {
headers: { accept: "image/png,image/jpeg,image/webp" },
signal: AbortSignal.timeout(8_000),
},
});
return result?.response ?? null;
}

async function mirrorOgImage(imageUrl: string | null, userId: string): Promise<string | null> {
if (!imageUrl) return null;
const url = new URL(imageUrl);
await assertPublicHostname(url.hostname);
const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(8_000) });
if (!response.ok) return null;
const type = (response.headers.get("content-type") ?? "").split(";")[0] ?? "";
if (!["image/png", "image/jpeg", "image/webp"].includes(type)) return null;
let bytes: Uint8Array;
try { bytes = await readBoundedBytes(response, 2 * 1024 * 1024); }
catch { return null; }
const ownedBytes = new Uint8Array(bytes);
const result = await storeImage(`tool-previews/${userId}`, new File([ownedBytes.buffer], "og-image", { type }));
return result.ok ? result.url ?? null : null;
try {
const response = await fetchPublicImage(new URL(imageUrl));
if (!response?.ok) return null;
const type = (response.headers.get("content-type") ?? "").split(";")[0] ?? "";
if (!["image/png", "image/jpeg", "image/webp"].includes(type)) return null;
const bytes = await readBoundedBytes(response, 2 * 1024 * 1024);
const ownedBytes = new Uint8Array(bytes);
const result = await storeImage(`tool-previews/${userId}`, new File([ownedBytes.buffer], "og-image", { type }));
return result.ok ? result.url ?? null : null;
} catch {
// A preview image is optional. DNS, redirect, size, or storage failures
// fail closed by omitting the image rather than blocking the listing.
return null;
}
}

export async function fetchToolMetadata(inputUrl: string, verifiedDomain: string, userId: string, options: { mirrorImage?: boolean } = { mirrorImage: true }): Promise<ToolMetadata & { canonicalUrl: string }> {
Expand Down
36 changes: 36 additions & 0 deletions apps/web/test/manual-redirect-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";

import { fetchWithManualRedirects } from "../src/lib/manual-redirect-fetch";

describe("fetchWithManualRedirects", () => {
it("uses Workers-compatible manual redirects and validates every destination", async () => {
const fetcher = vi.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "https://cdn.example.com/card.png" } }))
.mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "content-type": "image/png" } }));
const validated: string[] = [];

const result = await fetchWithManualRedirects({
inputUrl: new URL("https://example.com/card"),
validateUrl: (url) => { validated.push(url.toString()); return url.protocol === "https:"; },
fetcher: fetcher as typeof fetch,
});

expect(result?.url.toString()).toBe("https://cdn.example.com/card.png");
expect(result?.response.status).toBe(200);
expect(validated).toEqual(["https://example.com/card", "https://cdn.example.com/card.png"]);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher.mock.calls.every(([, init]) => init?.redirect === "manual")).toBe(true);
});

it("fails closed before fetching a redirect that violates URL policy", async () => {
const fetcher = vi.fn().mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "http://private.example/card.png" } }));
const result = await fetchWithManualRedirects({
inputUrl: new URL("https://example.com/card"),
validateUrl: (url) => url.protocol === "https:",
fetcher: fetcher as typeof fetch,
});

expect(result).toBeNull();
expect(fetcher).toHaveBeenCalledTimes(1);
});
});
Loading