diff --git a/.changeset/funny-toys-confess.md b/.changeset/funny-toys-confess.md deleted file mode 100644 index 38ce2b9f1..000000000 --- a/.changeset/funny-toys-confess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@frames.js/debugger": patch ---- - -feat(debugger): frame image validation diff --git a/packages/debugger/app/components/frame-debugger.tsx b/packages/debugger/app/components/frame-debugger.tsx index 43285ee3c..4e31cc3cd 100644 --- a/packages/debugger/app/components/frame-debugger.tsx +++ b/packages/debugger/app/components/frame-debugger.tsx @@ -4,7 +4,6 @@ import { type Frame, ParsingReport, SupportedParsingSpecification, - FrameFlattened, } from "frames.js"; import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import React from "react"; @@ -45,11 +44,9 @@ import { cn } from "@/lib/utils"; import { hasWarnings } from "../lib/utils"; import { useRouter } from "next/navigation"; import { WithTooltip } from "./with-tooltip"; -import { InvalidImageAspectRatioError, InvalidImageError, InvalidImageTypeError, validateFrameImage } from "../lib/validateFrameImage"; type FrameDebuggerFramePropertiesTableRowsProps = { stackItem: FramesStackItem; - specification: SupportedParsingSpecification; }; function paramsToObject(entries: IterableIterator<[string, string]>): object { @@ -74,9 +71,7 @@ function isPropertyExperimental([key, value]: [string, string]) { function FrameDebuggerFramePropertiesTableRow({ stackItem, - specification, }: FrameDebuggerFramePropertiesTableRowsProps) { - const [currentStackItem, setCurrentStackItem] = useState(stackItem); const properties = useMemo(() => { /** tuple of key and value */ const validProperties: [string, string][] = []; @@ -84,19 +79,19 @@ function FrameDebuggerFramePropertiesTableRow({ const invalidProperties: [string, ParsingReport[]][] = []; const visitedInvalidProperties: string[] = []; - if (currentStackItem.status === "pending") { + if (stackItem.status === "pending") { return { validProperties, invalidProperties, isValid: true }; } - if (currentStackItem.status === "requestError") { + if (stackItem.status === "requestError") { return { validProperties, invalidProperties, isValid: false }; } - if (currentStackItem.status === "message") { + if (stackItem.status === "message") { return { validProperties, invalidProperties, isValid: true }; } - const result = currentStackItem.frame; + const result = stackItem.frame; // we need to check validation errors first because getFrame incorrectly return a value for a key even if it's invalid for (const [key, errors] of Object.entries(result.reports)) { @@ -107,8 +102,7 @@ function FrameDebuggerFramePropertiesTableRow({ const flattenedFrame = getFrameFlattened(result.frame, { "frames.js:version": - "frames.js:version" in result.frame && - typeof result.frame["frames.js:version"] === "string" + "frames.js:version" in result.frame && typeof result.frame["frames.js:version"] === "string" ? result.frame["frames.js:version"] : undefined, }); @@ -136,90 +130,8 @@ function FrameDebuggerFramePropertiesTableRow({ isValid: invalidProperties.length === 0, hasExperimentalProperties, }; - }, [currentStackItem]); - - useEffect(() => { - setCurrentStackItem(stackItem); }, [stackItem]); - useEffect(() => { - if (stackItem.status === "done" && stackItem.frame.frame.image) { - const imageKey: keyof FrameFlattened = - specification === "farcaster" ? "fc:frame:image" : "of:image"; - const imageAspectRatioKey: keyof FrameFlattened = - specification === "farcaster" - ? "fc:frame:image:aspect_ratio" - : "of:image:aspect_ratio"; - - - const src = stackItem.frame.frame.image; - - validateFrameImage({ - src, - aspectRatio: stackItem.frame.frame.imageAspectRatio ?? "", - }).catch(e => { - if (e instanceof InvalidImageAspectRatioError) { - setCurrentStackItem({ - ...stackItem, - frame: { - ...stackItem.frame, - status: "failure", - reports: { - ...stackItem.frame.reports, - [imageAspectRatioKey]: [ - ...(stackItem.frame.reports[imageAspectRatioKey] ?? []), - { - source: specification, - level: "error", - message: e.message, - }, - ], - }, - }, - }); - } else if (e instanceof InvalidImageTypeError || e instanceof InvalidImageError) { - setCurrentStackItem({ - ...stackItem, - frame: { - ...stackItem.frame, - status: "failure", - reports: { - ...stackItem.frame.reports, - [imageKey]: [ - ...(stackItem.frame.reports[imageKey] ?? []), - { - source: specification, - level: "error", - message: e.message, - }, - ], - }, - }, - }); - } else { - setCurrentStackItem({ - ...stackItem, - frame: { - ...stackItem.frame, - status: "failure", - reports: { - ...stackItem.frame.reports, - [imageKey]: [ - ...(stackItem.frame.reports[imageKey] ?? []), - { - source: specification, - level: "error", - message: e instanceof Error ? e.message : `Failed to load image, invalid file type or corrupted image file`, - }, - ], - }, - }, - }); - } - }); - } - }, [stackItem, specification]); - return ( <> {properties.validProperties.map(([propertyKey, value]) => { @@ -855,7 +767,6 @@ export function FrameDebugger({ diff --git a/packages/debugger/app/image-proxy/route.ts b/packages/debugger/app/image-proxy/route.ts deleted file mode 100644 index cf09ec4f9..000000000 --- a/packages/debugger/app/image-proxy/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Allows to load images using fetch in client side to avoid CORS issues. - */ -export async function GET(req: Request): Promise { - const url = new URL(req.url); - - try { - const imgUrl = url.searchParams.get("url"); - - if (!imgUrl) { - throw new Error("Missing image url"); - } - - return fetch(new URL(imgUrl)); - } catch (e) { - return Response.json(String(e), { status: 400 }); - } -} diff --git a/packages/debugger/app/lib/validateFrameImage.ts b/packages/debugger/app/lib/validateFrameImage.ts deleted file mode 100644 index 981db120f..000000000 --- a/packages/debugger/app/lib/validateFrameImage.ts +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { fileTypeFromBuffer, type FileTypeResult } from "file-type"; - -const allowedImageTypes = ["image/gif", "image/jpeg", "image/png"]; - -export class InvalidImageError extends Error { - constructor() { - super( - "Failed to load image, possibly corrupted image or invalid file type." - ); - } -} - -export class InvalidImageTypeError extends Error { - constructor(detectedType: FileTypeResult | undefined) { - super( - detectedType - ? `Invalid image, only ${allowedImageTypes.join(", ")} are allowed, ${detectedType.mime} was detected` - : "Failed to load image, unrecognized content type." - ); - } -} - -export class InvalidImageAspectRatioError extends Error { - constructor({ width, height }: { width: number; height: number }) { - super( - `Image aspect ratio does not match defined aspect ratio, detect aspect ratio is ${width / height}:1` - ); - } -} - -type ValidateFrameImageOptions = { - src: string; - aspectRatio: string; -}; - -export async function validateFrameImage({ - src, - aspectRatio, -}: ValidateFrameImageOptions) { - const image = await new Promise((resolve, reject) => { - const img = new Image(); - - img.onload = () => { - resolve(img); - img.onload = null; - img.onerror = null; - }; - - img.onerror = (e) => { - reject(e); - img.onerror = null; - img.onload = null; - }; - - img.src = src; - }).catch(() => { - throw new InvalidImageError(); - }); - - const url = new URL(image.src); - let buffer: ArrayBuffer; - - if (url.protocol === "data:") { - // this is data url, convert the value to Buffer - const data = url.pathname.split(",")[1]; - buffer = Buffer.from(data!, "base64"); - } else { - buffer = await ( - await fetch( - `/image-proxy?${new URLSearchParams({ url: url.toString() })}` - ) - ).arrayBuffer(); - } - - const fileType = await fileTypeFromBuffer(buffer); - - if ( - !fileType || - !["image/gif", "image/jpeg", "image/png"].includes(fileType.mime) - ) { - throw new InvalidImageTypeError(fileType); - } - - const detectedAspectRatio = image.width / image.height; - const [width, height] = aspectRatio.split(":").map(Number); - - if (!width || !height) { - throw new Error("Invalid aspect ratio provided"); - } - - if (detectedAspectRatio !== width / height) { - throw new InvalidImageAspectRatioError({ - width: image.width, - height: image.height, - }); - } -} diff --git a/packages/debugger/next.config.js b/packages/debugger/next.config.js index d5d852c07..fc18bf2ba 100644 --- a/packages/debugger/next.config.js +++ b/packages/debugger/next.config.js @@ -18,7 +18,7 @@ const nextConfig = { }, ], }, - webpack: (config, context) => { + webpack: (config) => { config.externals.push( "pino-pretty", "lokijs", @@ -28,17 +28,6 @@ const nextConfig = { // so it is installed on user's system "@xmtp/user-preferences-bindings-wasm" ); - - // fixes file-types package so we can import it client side - config.plugins.push( - new context.webpack.NormalModuleReplacementPlugin( - /^node:/, - (resource) => { - resource.request = resource.request.replace(/^node:/, ""); - } - ) - ); - return config; }, }; diff --git a/packages/debugger/package.json b/packages/debugger/package.json index 99754a817..135fc2d3b 100644 --- a/packages/debugger/package.json +++ b/packages/debugger/package.json @@ -60,7 +60,6 @@ "cmdk": "^0.2.1", "eslint": "^8.56.0", "eslint-config-next": "^14.1.0", - "file-type": "^19.0.0", "frames.js": "^0.15.2", "lucide-react": "^0.344.0", "postcss": "^8", diff --git a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/frames.ts b/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/frames.ts deleted file mode 100644 index 9e0fd07d8..000000000 --- a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/frames.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createFrames } from "frames.js/next"; -import { appURL } from "../../../utils"; - -export const frames = createFrames({ - basePath: "/examples/new-api-invalid-images/frames", - baseUrl: appURL(), -}); diff --git a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-aspect-ratio/route.tsx b/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-aspect-ratio/route.tsx deleted file mode 100644 index 3717a5154..000000000 --- a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-aspect-ratio/route.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-disable react/jsx-key */ -import { Button } from "frames.js/next"; -import { frames } from "../frames"; - -const handler = frames(async () => { - return { - // 20x20 image - image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlElEQVR4nO2UQQrEIAxFe/8jSRUrKtZFEasIQs7yh3RRKEOnixmYjYsQ0OT5E2ImIsIvbRpAjB7iq7GptcIYg947rLUopbwl5JzPGPattXtgSgkhBAghsG3b4fncOQfv/RnDIL7jB+d5/qxQKYV1XS9ArTWWZbkoZHsExhgPICfdlcxKpZTY9/25ZBp/mcb6on9s7Bc+TJAvSO7XjwAAAABJRU5ErkJggg==", - imageOptions: { - aspectRatio: '1.91:1', - }, - buttons: [ - - ], - }; -}); - -export const GET = handler; -export const POST = handler; diff --git a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-image-type/route.tsx b/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-image-type/route.tsx deleted file mode 100644 index 3f15ea2ef..000000000 --- a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-image-type/route.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable react/jsx-key */ -import { Button } from "frames.js/next"; -import { frames } from "../frames"; - -const handler = frames(async (ctx) => { - return { - image: new URL('/bitmap.bmp', ctx.baseUrl).toString(), - buttons: [ - - ], - }; -}); - -export const GET = handler; -export const POST = handler; diff --git a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-image/route.tsx b/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-image/route.tsx deleted file mode 100644 index 660675a9d..000000000 --- a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/invalid-image/route.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable react/jsx-key */ -import { Button } from "frames.js/next"; -import { frames } from "../frames"; - -const handler = frames(async () => { - return { - image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAAAAABDU1VNAAAABGdBTUEAAYagMeiWXwAAAEFJREFUeJxjZGAkABQIyLMMBQWMDwgp+PcfP2B5MBwUMMoRkGdkonlcDAYFjI/wyv7/z/iH5nExGBQwyuCVZWQEAFDl/nE14thZAAAAAElFTkSuQmCC", - buttons: [ - - ], - }; -}); - -export const GET = handler; -export const POST = handler; diff --git a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/route.tsx b/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/route.tsx deleted file mode 100644 index 52d30f2e6..000000000 --- a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/frames/route.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable react/jsx-key */ -import { Button } from "frames.js/next"; -import { frames } from "./frames"; - -const handler = frames(async () => { - return { - image:
Choose an error
, - buttons: [ - , - , - , - ], - }; -}); - -export const GET = handler; -export const POST = handler; diff --git a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/page.tsx b/templates/next-starter-with-examples/app/examples/new-api-invalid-images/page.tsx deleted file mode 100644 index eb4d2e476..000000000 --- a/templates/next-starter-with-examples/app/examples/new-api-invalid-images/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Link from "next/link"; -import { currentURL, appURL } from "../../utils"; -import { createDebugUrl } from "../../debug"; -import type { Metadata } from "next"; -import { fetchMetadata } from "frames.js/next"; - -export async function generateMetadata(): Promise { - return { - title: "New api example", - description: "This is a new api example", - other: { - ...(await fetchMetadata( - new URL("/examples/new-api-invalid-images/frames", appURL()) - )), - }, - }; -} - -export default async function Home() { - const url = currentURL("/examples/new-api-invalid-images"); - - return ( -
- New api error example.{" "} - - Debug - -
- ); -} diff --git a/templates/next-starter-with-examples/public/bitmap.bmp b/templates/next-starter-with-examples/public/bitmap.bmp deleted file mode 100644 index 0a008935e..000000000 Binary files a/templates/next-starter-with-examples/public/bitmap.bmp and /dev/null differ diff --git a/yarn.lock b/yarn.lock index 5048359ef..26513aad0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3646,11 +3646,6 @@ dependencies: "@tanstack/query-core" "5.28.6" -"@tokenizer/token@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" - integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== - "@types/acorn@^4.0.0": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22" @@ -7649,15 +7644,6 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@^19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-19.0.0.tgz#62a6cadc43f73ba38c53e1a174943a75fdafafa9" - integrity sha512-s7cxa7/leUWLiXO78DVVfBVse+milos9FitauDLG1pI7lNaJ2+5lzPnr2N24ym+84HVwJL6hVuGfgVE+ALvU8Q== - dependencies: - readable-web-to-node-stream "^3.0.2" - strtok3 "^7.0.0" - token-types "^5.0.1" - fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" @@ -11907,11 +11893,6 @@ pathval@^1.1.1: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== -peek-readable@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-5.0.0.tgz#7ead2aff25dc40458c60347ea76cfdfd63efdfec" - integrity sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A== - peek-stream@^1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67" @@ -12637,13 +12618,6 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0, readable process "^0.11.10" string_decoder "^1.3.0" -readable-web-to-node-stream@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" - integrity sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw== - dependencies: - readable-stream "^3.6.0" - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -13732,14 +13706,6 @@ strip-literal@^2.0.0: dependencies: js-tokens "^8.0.2" -strtok3@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-7.0.0.tgz#868c428b4ade64a8fd8fee7364256001c1a4cbe5" - integrity sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ== - dependencies: - "@tokenizer/token" "^0.3.0" - peek-readable "^5.0.0" - style-to-object@^0.4.0, style-to-object@^0.4.1: version "0.4.4" resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.4.tgz#266e3dfd56391a7eefb7770423612d043c3f33ec" @@ -14030,14 +13996,6 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -token-types@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/token-types/-/token-types-5.0.1.tgz#aa9d9e6b23c420a675e55413b180635b86a093b4" - integrity sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg== - dependencies: - "@tokenizer/token" "^0.3.0" - ieee754 "^1.2.1" - toml@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee"