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
27 changes: 27 additions & 0 deletions __tests__/components/nft-image/utils/animation-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,33 @@ describe("getResolvedAnimationSrc", () => {
);
});

it("uses metadata animation fields for HTML, not top-level animation", () => {
const nft = createMockNFT({
animation: "https://example.com/processed-or-proxy.html",
metadata: {
animation_details: { format: "html" },
animation_url: "https://example.com/original.html",
},
});

expect(getResolvedAnimationSrc(nft)).toBe(
"https://example.com/original.html"
);
});

it("returns undefined for HTML format when metadata animation fields are empty", () => {
const nft = createMockNFT({
animation: "https://example.com/processed-or-proxy.html",
metadata: {
animation_details: { format: "html" },
animation: "",
animation_url: undefined,
},
});

expect(getResolvedAnimationSrc(nft)).toBeUndefined();
});

it("returns undefined when every candidate is empty or invalid", () => {
const nft = createMockNFT({
animation: " ",
Expand Down
35 changes: 35 additions & 0 deletions components/nft-image/NFTModel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
import "@google/model-viewer";
import { useEffect, useMemo, useRef } from "react";
import type { SyntheticEvent } from "react";
import type { BaseNFT } from "@/entities/INFT";
import { getResolvedAnimationSrc } from "./utils/animation-source";
import { withArweaveFallback } from "./utils/arweave-fallback";

type ModelViewerElement = HTMLElement & {
src: string;
};

export default function NFTModel(
props: Readonly<{ nft: BaseNFT; id?: string | undefined }>
) {
const modelRef = useRef<ModelViewerElement | null>(null);
const handleArweaveError = useMemo(() => withArweaveFallback(), []);

useEffect(() => {
const modelElement = modelRef.current;
if (!modelElement) {
return;
}

const nativeErrorHandler = (event: Event) => {
const currentTarget = event.currentTarget as ModelViewerElement | null;
if (!currentTarget) {
return;
}

handleArweaveError({
currentTarget,
} as SyntheticEvent<ModelViewerElement, Event>);
};

modelElement.addEventListener("error", nativeErrorHandler);

return () => {
modelElement.removeEventListener("error", nativeErrorHandler);
};
}, [handleArweaveError]);

return (
// @ts-ignore
<model-viewer
ref={modelRef}
id={props.id ?? `iframe-${props.nft.id}`}
src={getResolvedAnimationSrc(props.nft)}
alt={props.nft.name}
Expand Down
66 changes: 61 additions & 5 deletions components/nft-image/renderers/NFTHTMLRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { Col } from "react-bootstrap";
import styles from "../NFTImage.module.scss";
import NFTImageBalance from "../NFTImageBalance";
import type { BaseRendererProps } from "../types/renderer-props";
import { getResolvedAnimationSrc } from "../utils/animation-source";
import { getArweaveGatewayFallbackUrls } from "../utils/arweave-fallback";

const IFRAME_FALLBACK_TIMEOUT_MS = 8000;

function getSrc(nft: BaseRendererProps["nft"]): string | undefined {
return getResolvedAnimationSrc(nft);
Expand All @@ -13,6 +17,51 @@ function getSrc(nft: BaseRendererProps["nft"]): string | undefined {
export default function NFTHTMLRenderer(props: Readonly<BaseRendererProps>) {
const src = getSrc(props.nft);
const animationClassName = styles["nftAnimation"] ?? "";
const urls = useMemo(
() => (src ? getArweaveGatewayFallbackUrls(src) : []),
[src]
);
const [activeIndex, setActiveIndex] = useState(0);
const [didLoadCurrentUrl, setDidLoadCurrentUrl] = useState(false);
const activeUrl = urls[activeIndex];

useEffect(() => {
if (urls.length === 0) {
setActiveIndex(0);
setDidLoadCurrentUrl(false);
return;
}
setActiveIndex(0);
setDidLoadCurrentUrl(false);
}, [urls]);

useEffect(() => {
setDidLoadCurrentUrl(false);
}, [activeUrl]);

useEffect(() => {
if (!activeUrl || didLoadCurrentUrl || activeIndex + 1 >= urls.length) {
return;
}

const timeoutId = globalThis.setTimeout(() => {
setActiveIndex((current) =>
current === activeIndex && current + 1 < urls.length
? current + 1
: current
);
}, IFRAME_FALLBACK_TIMEOUT_MS);

return () => {
globalThis.clearTimeout(timeoutId);
};
}, [activeIndex, activeUrl, didLoadCurrentUrl, urls.length]);

const advanceToNextUrl = () => {
setActiveIndex((current) =>
current + 1 < urls.length ? current + 1 : current
);
};

return (
<Col
Expand All @@ -25,11 +74,18 @@ export default function NFTHTMLRenderer(props: Readonly<BaseRendererProps>) {
height={props.height}
/>
)}
<iframe
title={props.id}
src={src}
id={props.id ?? `iframe-${props.nft.id}`}
/>
{activeUrl ? (
<iframe
title={props.id}
src={activeUrl}
id={props.id ?? `iframe-${props.nft.id}`}
key={`${props.nft.contract}-${props.nft.id}-${activeUrl}`}
onLoad={() => {
setDidLoadCurrentUrl(true);
}}
onError={advanceToNextUrl}
/>
) : null}
</Col>
);
}
13 changes: 8 additions & 5 deletions components/nft-image/utils/animation-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ export function getResolvedAnimationSrc(
? (nft.metadata as {
readonly animation?: unknown;
readonly animation_url?: unknown;
readonly animation_details?: { readonly format?: unknown };
})
: undefined;

const candidates = [
nft.animation,
metadata?.animation,
metadata?.animation_url,
];
const format = metadata?.animation_details?.format;
const isHtmlAnimation =
typeof format === "string" && format.toLowerCase() === "html";

const candidates = isHtmlAnimation
? [metadata?.animation, metadata?.animation_url]
: [nft.animation, metadata?.animation, metadata?.animation_url];

for (const candidate of candidates) {
const resolved = normalizeNonEmptyString(candidate);
Expand Down
43 changes: 26 additions & 17 deletions components/nft-image/utils/arweave-fallback.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type React from "react";

const ARWEAVE_GATEWAYS: readonly string[] = [
"arweave.net",
"gateway.arweave.net",
"gateway.ar.io",
"ar-io.net",
] as const;
import {
ARWEAVE_FALLBACK_HOSTS,
canonicalizeArweaveGatewayHostname,
isArweaveGatewayRuntimeHost,
} from "@/lib/media/arweave-gateways";

function dedupe(list: readonly string[]): string[] {
return Array.from(new Set(list));
Expand All @@ -20,15 +18,11 @@ function safeParseUrl(url: string): URL | null {
}

function normalizeHost(hostname: string): string {
const h = hostname.toLowerCase();

if (h === "arweave.net" || h.endsWith(".arweave.net")) return "arweave.net";

return h;
return canonicalizeArweaveGatewayHostname(hostname);
}

function isArweaveGatewayHost(hostname: string): boolean {
return ARWEAVE_GATEWAYS.includes(normalizeHost(hostname));
return isArweaveGatewayRuntimeHost(hostname);
}

function isArweaveUrl(url: string): boolean {
Expand All @@ -48,13 +42,28 @@ function buildUrlWithGateway(
return u.toString();
}

type MediaErrorEvent =
| React.SyntheticEvent<HTMLImageElement, Event>
| React.SyntheticEvent<HTMLVideoElement, Event>;
type MediaErrorEvent = React.SyntheticEvent<
| HTMLImageElement
| HTMLVideoElement
| HTMLIFrameElement
| (HTMLElement & { src: string }),
Event
>;

const DS_ORIGINAL = "arweaveOriginalSrc";
const DS_LAST_HOST = "arweaveLastGatewayHost";

export function getArweaveGatewayFallbackUrls(url: string): string[] {
const trimmed = url.trim();
if (!trimmed) {
return [];
}
if (!isArweaveUrl(trimmed)) {
return [trimmed];
}
return getTryList(trimmed, trimmed);
}

function getTryList(currentSrc: string, originalSrc: string): string[] {
const current = safeParseUrl(currentSrc);
const orig = safeParseUrl(originalSrc);
Expand All @@ -67,7 +76,7 @@ function getTryList(currentSrc: string, originalSrc: string): string[] {
const origHost = orig ? normalizeHost(orig.hostname) : null;

// Build gateway variants from originalSrc (preserves path/query exactly).
const variants = ARWEAVE_GATEWAYS.filter((h) => h !== origHost) // original already first in base
const variants = ARWEAVE_FALLBACK_HOSTS.filter((h) => h !== origHost) // original already first in base
.filter((h) => h !== currentHost) // skip current host too
.map((h) => buildUrlWithGateway(originalSrc, h))
.filter((u): u is string => !!u);
Expand Down
6 changes: 3 additions & 3 deletions components/waves/memes/MemesArtSubmissionFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ const MemesArtSubmissionFile: React.FC<MemesArtSubmissionFileProps> = ({
if (externalValidationStatus === "invalid" && externalError) {
return renderPreviewMessage(
externalError,
"Only ipfs.io or arweave.net HTML documents can be embedded."
"Only approved IPFS or Arweave gateway HTML documents can be embedded."
);
}

Expand Down Expand Up @@ -374,7 +374,7 @@ const MemesArtSubmissionFile: React.FC<MemesArtSubmissionFileProps> = ({
id="memes-interactive-media-hash"
type="text"
autoComplete="off"
className="tw-form-input tw-w-full tw-cursor-text tw-rounded-lg tw-border-0 tw-bg-iron-900 tw-px-3 tw-py-2.5 tw-text-base sm:tw-text-sm tw-font-normal tw-text-iron-100 tw-outline-none tw-ring-1 tw-ring-iron-700 tw-transition-all tw-duration-500 tw-ease-in-out placeholder:tw-text-iron-500 focus-visible:tw-ring-2 focus-visible:tw-ring-primary-400 focus-visible:hover:tw-ring-primary-400 desktop-hover:hover:tw-ring-iron-650"
className="tw-form-input tw-w-full tw-cursor-text tw-rounded-lg tw-border-0 tw-bg-iron-900 tw-px-3 tw-py-2.5 tw-text-base tw-font-normal tw-text-iron-100 tw-outline-none tw-ring-1 tw-ring-iron-700 tw-transition-all tw-duration-500 tw-ease-in-out placeholder:tw-text-iron-500 focus-visible:tw-ring-2 focus-visible:tw-ring-primary-400 focus-visible:hover:tw-ring-primary-400 desktop-hover:hover:tw-ring-iron-650 sm:tw-text-sm"
placeholder="bafy.../index.html"
value={externalHash}
onChange={(event) => onExternalHashChange(event.target.value)}
Expand Down Expand Up @@ -429,7 +429,7 @@ const MemesArtSubmissionFile: React.FC<MemesArtSubmissionFileProps> = ({
containerClassName="tw-flex-1 tw-flex tw-flex-col"
fallback={renderPreviewMessage(
"Preview unavailable for unapproved domains or file types.",
"Only ipfs.io or arweave.net HTML documents can be embedded."
"Only approved IPFS or Arweave gateway HTML documents can be embedded."
)}
/>
) : (
Expand Down
18 changes: 6 additions & 12 deletions components/waves/memes/submission/constants/security.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { InteractiveMediaProvider } from "./media";
import {
canonicalizeArweaveGatewayHostname,
isArweaveGatewayRuntimeHost,
} from "@/lib/media/arweave-gateways";

const INTERACTIVE_MEDIA_IPFS_HOSTS = new Set<string>([
"ipfs.io",
"www.ipfs.io",
]);

const ARWEAVE_ROOT_HOSTS = new Set<string>(["arweave.net", "www.arweave.net"]);
const ARWEAVE_SUBDOMAIN_PATTERN = /^([a-z0-9_-]{43,87})\.arweave\.net$/;

const CIDV0_PATTERN = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/;
Expand All @@ -19,11 +22,7 @@ const ARWEAVE_PATH_PATTERN = /^\/([^/]+)$/;
export const canonicalizeInteractiveMediaHostname = (
hostname: string
): string => {
let normalized = hostname.toLowerCase();
while (normalized.endsWith(".")) {
normalized = normalized.slice(0, -1);
}
return normalized;
return canonicalizeArweaveGatewayHostname(hostname);
};

const isIpfsHost = (hostname: string): boolean =>
Expand All @@ -40,12 +39,7 @@ const getArweaveTransactionIdFromSubdomain = (
};

const isArweaveHost = (hostname: string): boolean => {
const normalized = canonicalizeInteractiveMediaHostname(hostname);
if (ARWEAVE_ROOT_HOSTS.has(normalized)) {
return true;
}

return getArweaveTransactionIdFromSubdomain(hostname) !== null;
return isArweaveGatewayRuntimeHost(hostname);
};

const getInteractiveMediaProviderForHost = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
INTERACTIVE_MEDIA_GATEWAY_BASE_URL,
isInteractiveMediaContentIdentifier,
} from "../constants/security";
import { stripArweaveGatewayUrlPrefix } from "@/lib/media/arweave-gateways";
import type {
AdditionalMedia,
AirdropEntry,
Expand Down Expand Up @@ -99,7 +100,7 @@ const sanitizeInteractiveHash = (
value = value.replace(/^https?:\/\/[^/]+\/ipfs\//i, "");
value = value.replace(/^ipfs\//i, "");
} else {
value = value.replace(/^https?:\/\/(?:www\.)?arweave\.net\//i, "");
value = stripArweaveGatewayUrlPrefix(value);
}

value = value.replace(/^\/+/, "");
Expand Down
7 changes: 5 additions & 2 deletions config/nextConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createSecurityHeaders } from "./securityHeaders";
import { PublicEnv } from "./env.schema";
import { NextConfig } from "next";
import { ARWEAVE_GATEWAY_REMOTE_PATTERN_HOSTNAMES } from "../lib/media/arweave-gateways";

export function sharedConfig(
publicEnv: PublicEnv,
Expand All @@ -18,8 +19,10 @@ export function sharedConfig(
remotePatterns: [
{ protocol: "https", hostname: "6529.io" },
{ protocol: "https", hostname: "staging.6529.io" },
{ protocol: "https", hostname: "arweave.net" },
{ protocol: "https", hostname: "ar-io.net" },
...ARWEAVE_GATEWAY_REMOTE_PATTERN_HOSTNAMES.map((hostname) => ({
protocol: "https" as const,
hostname,
})),
{ protocol: "http", hostname: "localhost" },
{ protocol: "https", hostname: "media.generator.seize.io" },
{ protocol: "https", hostname: "d3lqz0a4bldqgf.cloudfront.net" },
Expand Down
6 changes: 5 additions & 1 deletion config/securityHeaders.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { ARWEAVE_GATEWAY_CSP_SOURCES } from "../lib/media/arweave-gateways";

export function createSecurityHeaders(apiEndpoint: string | undefined = "") {
const arweaveGatewaySources = ARWEAVE_GATEWAY_CSP_SOURCES.join(" ");

return [
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload",
},
{
key: "Content-Security-Policy",
value: `default-src 'none'; script-src 'self' 'unsafe-inline' https://dnclu2fna0b2b.cloudfront.net https://www.google-analytics.com https://www.googletagmanager.com/ https://dataplane.rum.us-east-1.amazonaws.com 'unsafe-eval'; connect-src * 'self' blob: ${apiEndpoint} https://registry.walletconnect.com/api/v2/wallets wss://*.bridge.walletconnect.org wss://*.walletconnect.com wss://www.walletlink.org/rpc https://explorer-api.walletconnect.com/v3/wallets https://www.googletagmanager.com https://*.google-analytics.com https://cloudflare-eth.com/ https://arweave.net/* https://rpc.walletconnect.com/v1/ https://sts.us-east-1.amazonaws.com https://sts.us-west-2.amazonaws.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com; img-src 'self' data: blob: ipfs: https://artblocks.io https://*.artblocks.io *; media-src 'self' blob: https://*.cloudfront.net https://videos.files.wordpress.com https://arweave.net https://*.arweave.net https://cf-ipfs.com/ipfs/* https://*.twimg.com https://artblocks.io https://*.artblocks.io; frame-src 'self' https://ipfs.io https://ipfs.io/ipfs/ https://cf-ipfs.com https://cf-ipfs.com/ipfs/ https://media.generator.seize.io https://media.generator.6529.io https://generator.seize.io https://arweave.net https://*.arweave.net https://nftstorage.link https://*.ipfs.nftstorage.link https://verify.walletconnect.com https://verify.walletconnect.org https://secure.walletconnect.com https://d3lqz0a4bldqgf.cloudfront.net https://www.youtube.com https://www.youtube-nocookie.com https://*.youtube.com https://artblocks.io https://*.artblocks.io https://docs.google.com https://drive.google.com https://*.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/css2 https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com http://cdnjs.cloudflare.com https://cdn.jsdelivr.net; object-src data:;`,
value: `default-src 'none'; script-src 'self' 'unsafe-inline' https://dnclu2fna0b2b.cloudfront.net https://www.google-analytics.com https://www.googletagmanager.com/ https://dataplane.rum.us-east-1.amazonaws.com 'unsafe-eval'; connect-src * 'self' blob: ${apiEndpoint} https://registry.walletconnect.com/api/v2/wallets wss://*.bridge.walletconnect.org wss://*.walletconnect.com wss://www.walletlink.org/rpc https://explorer-api.walletconnect.com/v3/wallets https://www.googletagmanager.com https://*.google-analytics.com https://cloudflare-eth.com/ ${arweaveGatewaySources} https://rpc.walletconnect.com/v1/ https://sts.us-east-1.amazonaws.com https://sts.us-west-2.amazonaws.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com; img-src 'self' data: blob: ipfs: https://artblocks.io https://*.artblocks.io *; media-src 'self' blob: https://*.cloudfront.net https://videos.files.wordpress.com ${arweaveGatewaySources} https://cf-ipfs.com/ipfs/* https://*.twimg.com https://artblocks.io https://*.artblocks.io; frame-src 'self' https://ipfs.io https://ipfs.io/ipfs/ https://cf-ipfs.com https://cf-ipfs.com/ipfs/ https://media.generator.seize.io https://media.generator.6529.io https://generator.seize.io ${arweaveGatewaySources} https://nftstorage.link https://*.ipfs.nftstorage.link https://verify.walletconnect.com https://verify.walletconnect.org https://secure.walletconnect.com https://d3lqz0a4bldqgf.cloudfront.net https://www.youtube.com https://www.youtube-nocookie.com https://*.youtube.com https://artblocks.io https://*.artblocks.io https://docs.google.com https://drive.google.com https://*.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/css2 https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com http://cdnjs.cloudflare.com https://cdn.jsdelivr.net; object-src data:;`,
},
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
Expand Down
Loading
Loading