diff --git a/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx index 5218923ca7..516f92737e 100644 --- a/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx @@ -17,21 +17,21 @@ export const generateMetadata = async ({ params }: _PageProps) => { if (!p.success) { return notFound(); } - - const props = await getStaticProps(p.data.slug); + const slugFromUrl = p.data.slug; + const props = await getStaticProps(slugFromUrl); if (!props) { notFound(); } - const { name, logo, description } = props.data; + const { name, logo, dirName: appStoreDirSlug, slug: appSlug, description } = props.data; return await generateAppMetadata( - { slug: logo, name, description }, + { slug: appStoreDirSlug ?? appSlug, logoUrl: logo, name, description }, () => name, () => description, undefined, undefined, - `/apps/${p.data.slug}` + `/apps/${appSlug}` ); }; diff --git a/apps/web/app/_utils.tsx b/apps/web/app/_utils.tsx index 166b5782ef..8efbd21dc2 100644 --- a/apps/web/app/_utils.tsx +++ b/apps/web/app/_utils.tsx @@ -66,10 +66,10 @@ export const _generateMetadata = async ( ); const image = SEO_IMG_OGIMG + - constructGenericImage({ + (await constructGenericImage({ title: metadata.title, description: metadata.description, - }); + })); return { ...metadata, @@ -80,7 +80,7 @@ export const _generateMetadata = async ( }; }; -export const _generateMetadataForStaticPage = ( +export const _generateMetadataForStaticPage = async ( title: string, description: string, hideBranding?: boolean, @@ -108,10 +108,10 @@ export const _generateMetadataForStaticPage = ( }; const image = SEO_IMG_OGIMG + - constructGenericImage({ + (await constructGenericImage({ title: metadata.title, description: metadata.description, - }); + })); return { ...metadata, @@ -137,7 +137,7 @@ export const generateMeetingMetadata = async ( origin, pathname ); - const image = SEO_IMG_OGIMG + constructMeetingImage(meeting); + const image = SEO_IMG_OGIMG + (await constructMeetingImage(meeting)); return { ...metadata, @@ -164,7 +164,7 @@ export const generateAppMetadata = async ( pathname ); - const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description }); + const image = SEO_IMG_OGIMG + (await constructAppImage({ ...app, description: metadata.description })); return { ...metadata, diff --git a/apps/web/app/api/social/og/image/__tests__/route.test.ts b/apps/web/app/api/social/og/image/__tests__/route.test.ts index 6476e6dfa3..34512010ac 100644 --- a/apps/web/app/api/social/og/image/__tests__/route.test.ts +++ b/apps/web/app/api/social/og/image/__tests__/route.test.ts @@ -1,6 +1,8 @@ import { NextRequest } from "next/server"; import { describe, expect, test, vi, beforeEach } from "vitest"; +import { getOGImageVersion } from "@calcom/lib/OgImages"; + import { GET } from "../route"; vi.mock("next/og", () => ({ @@ -14,14 +16,19 @@ vi.mock("next/og", () => ({ })), })); -vi.mock("@calcom/lib/OgImages", () => ({ - Meeting: vi.fn(() => null), - App: vi.fn(() => null), - Generic: vi.fn(() => null), -})); +vi.mock("@calcom/lib/OgImages", async (importOriginal) => { + return await importOriginal(); +}); -vi.mock("@calcom/lib/constants", () => ({ - WEBAPP_URL: "http://localhost:3000", +vi.mock(import("@calcom/lib/constants"), async (importOriginal) => { + return await importOriginal(); +}); + +vi.mock("@calcom/web/public/app-store/svg-hashes.json", () => ({ + default: { + huddle01video: "81a0653b", + zoomvideo: "d1c78abf", + }, })); global.fetch = vi.fn(); @@ -82,7 +89,7 @@ describe("GET /api/social/og/image", () => { const response = await GET(request); expect(response.status).toBe(404); - expect(await response.text()).toBe("What you're looking for is not here.."); + expect(await response.text()).toBe("Wrong image type"); }); test("returns 404 when invalid type parameter is provided", async () => { @@ -90,7 +97,7 @@ describe("GET /api/social/og/image", () => { const response = await GET(request); expect(response.status).toBe(404); - expect(await response.text()).toBe("What you're looking for is not here.."); + expect(await response.text()).toBe("Wrong image type"); }); }); @@ -107,4 +114,33 @@ describe("GET /api/social/og/image", () => { expect(await response.text()).toBe("Internal server error"); }); }); + + describe("getOGImageVersion with SVG hash", () => { + test("app type: ETag changes when SVG hash is provided", async () => { + const etagWithoutHash = await getOGImageVersion("app"); + const etagWithHash = await getOGImageVersion("app", { + slug: "huddle01video", + svgHash: "81a0653b", + }); + + expect(etagWithoutHash).toBeTruthy(); + expect(etagWithHash).toBeTruthy(); + expect(etagWithHash).not.toBe(etagWithoutHash); + }); + + test("app type: different SVG hashes produce different ETags", async () => { + const etagHash1 = await getOGImageVersion("app", { + slug: "huddle01video", + svgHash: "81a0653b", + }); + const etagHash2 = await getOGImageVersion("app", { + slug: "zoomvideo", + svgHash: "d1c78abf", + }); + + expect(etagHash1).toBeTruthy(); + expect(etagHash2).toBeTruthy(); + expect(etagHash1).not.toBe(etagHash2); + }); + }); }); diff --git a/apps/web/app/api/social/og/image/route.tsx b/apps/web/app/api/social/og/image/route.tsx index 80abcf3a9e..3720bfa207 100644 --- a/apps/web/app/api/social/og/image/route.tsx +++ b/apps/web/app/api/social/og/image/route.tsx @@ -3,7 +3,7 @@ import type { NextRequest } from "next/server"; import type { SatoriOptions } from "satori"; import { z, ZodError } from "zod"; -import { Meeting, App, Generic } from "@calcom/lib/OgImages"; +import { Meeting, App, Generic, getOGImageVersion } from "@calcom/lib/OgImages"; import { WEBAPP_URL } from "@calcom/lib/constants"; export const runtime = "edge"; @@ -22,6 +22,7 @@ const appSchema = z.object({ name: z.string(), description: z.string(), slug: z.string(), + logoUrl: z.string(), }); const genericSchema = z.object({ @@ -74,6 +75,7 @@ async function handler(req: NextRequest) { imageType, }); + const etag = await getOGImageVersion("meeting"); const img = new ImageResponse( ( , ogConfig); + + // Get SVG hash for the app + const svgHashesModule = await import("@calcom/web/public/app-store/svg-hashes.json"); + const SVG_HASHES = svgHashesModule.default ?? {}; + const svgHash = SVG_HASHES[slug] ?? undefined; + + const etag = await getOGImageVersion("app", { svgHash }); + const img = new ImageResponse( + , + ogConfig + ); return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png", - "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", + "Cache-Control": + "public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000", + ETag: `"${etag}"`, }, }); } catch (error) { @@ -151,13 +168,16 @@ async function handler(req: NextRequest) { imageType, }); + const etag = await getOGImageVersion("generic"); const img = new ImageResponse(, ogConfig); return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png", - "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", + "Cache-Control": + "public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000", + ETag: `"${etag}"`, }, }); } catch (error) { @@ -178,9 +198,9 @@ async function handler(req: NextRequest) { } default: - return new Response("What you're looking for is not here..", { status: 404 }); + return new Response("Wrong image type", { status: 404 }); } - } catch (error) { + } catch { return new Response("Internal server error", { status: 500 }); } } diff --git a/apps/web/app/api/social/og/image/types.d.ts b/apps/web/app/api/social/og/image/types.d.ts new file mode 100644 index 0000000000..7498884061 --- /dev/null +++ b/apps/web/app/api/social/og/image/types.d.ts @@ -0,0 +1,4 @@ +declare module "@calcom/web/public/app-store/svg-hashes.json" { + const value: Record; + export default value; +} diff --git a/apps/web/app/icons/page.tsx b/apps/web/app/icons/page.tsx index 1fa1696cd9..affa5915e7 100644 --- a/apps/web/app/icons/page.tsx +++ b/apps/web/app/icons/page.tsx @@ -10,13 +10,10 @@ import { lucideIconList } from "../../../../packages/ui/components/icon/icon-lis import { IconGrid } from "./IconGrid"; export const dynamic = "force-static"; -export const metadata: Metadata = _generateMetadataForStaticPage( - "Icons Showcase", - "", - undefined, - undefined, - "/icons" -); + +export async function generateMetadata(): Promise { + return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons"); +} const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" }); const calFont = localFont({ diff --git a/apps/web/scripts/copy-app-store-static.js b/apps/web/scripts/copy-app-store-static.js index 68d74babaa..a9851b62de 100644 --- a/apps/web/scripts/copy-app-store-static.js +++ b/apps/web/scripts/copy-app-store-static.js @@ -1,21 +1,24 @@ const fs = require("fs"); const path = require("path"); const glob = require("glob"); +const crypto = require("crypto"); const copyAppStoreStatic = () => { // Get all static files from app-store packages const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true }); + // Object to store icon SVG hashes + const SVG_HASHES = {}; + staticFiles.forEach((file) => { // Extract app name from path const appNameMatch = file.match(/app-store\/(.*?)\/static/); if (!appNameMatch) return; - const appName = appNameMatch[1]; + const appDirName = appNameMatch[1]; const fileName = path.basename(file); - // Create destination directory if it doesn't exist - const destDir = path.join(process.cwd(), "public", "app-store", appName); + const destDir = path.join(process.cwd(), "public", "app-store", appDirName); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } @@ -23,8 +26,20 @@ const copyAppStoreStatic = () => { // Copy file to destination (Turborepo caching handles change detection) const destPath = path.join(destDir, fileName); fs.copyFileSync(file, destPath); + + // If it's an icon SVG file, compute hash + if (fileName.includes("icon") && fileName.endsWith(".svg")) { + const content = fs.readFileSync(file, "utf8"); + const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8); + SVG_HASHES[appDirName] = hash; + } + console.log(`Copied ${file} to ${destPath}`); }); + + // Write SVG hashes to a JSON file + const hashFilePath = path.join(process.cwd(), "public", "app-store", "svg-hashes.json"); + fs.writeFileSync(hashFilePath, JSON.stringify(SVG_HASHES, null, 2)); }; // Run the copy function diff --git a/packages/lib/OgImages.tsx b/packages/lib/OgImages.tsx index 9a1e62b765..e468fb639b 100644 --- a/packages/lib/OgImages.tsx +++ b/packages/lib/OgImages.tsx @@ -20,6 +20,7 @@ export interface AppImageProps { name: string; description: string; slug: string; + logoUrl: string; } export interface GenericImageProps { @@ -48,6 +49,48 @@ const joinMultipleNames = (names: string[] = []) => { const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CAL_URL}${url}`); +const OG_ASSETS = { + meeting: { + id: "meeting-og-image-v1", // Bump version when changing Meeting component structure/styling + logo: LOGO, + logoWidth: "350", + avatarSize: "160", + variant: "dark" as const, + }, + app: { + id: "app-og-image-v1", // Bump version when changing App component structure/styling + logo: LOGO, + logoWidth: "150", + iconSize: "172", + variant: "light" as const, + }, + generic: { + id: "generic-og-image-v1", // Bump version when changing Generic component structure/styling + logo: "cal-logo-word-black.svg", + logoWidth: "350", + variant: "light" as const, + }, +}; + +export const getOGImageVersion = async ( + type: keyof typeof OG_ASSETS, + additionalInputs?: Record +) => { + const versionInputs: Record = { + ...OG_ASSETS[type], + ...(additionalInputs ?? {}), + }; + + const content = JSON.stringify(versionInputs, Object.keys(versionInputs).sort()); + + // Use Web Crypto API instead of Node.js crypto for Edge Runtime compatibility (`/api/social/og/image` is an Edge Runtime route) + const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + + return hashHex.substring(0, 8); +}; + /** * Test urls: * 1. 1 user http://localhost:3000/api/social/og/image?type=meeting&title=super%20long%20event%20title%20for%20testing%20purposes&meetingProfileName=Pro%20Example&meetingImage=http://localhost:3000/pro/avatar.png&names=Pro%20Example&usernames=pro @@ -56,7 +99,11 @@ const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CA * 4. Team event (round robin) http://localhost:3000/api/social/og/image?type=meeting&title=Round%20Robin%20Seeded%20Team%20Event&meetingProfileName=Seeded%20Team * 5. Dynamic collective (2 persons) http://localhost:3000/api/social/og/image?type=meeting&title=15min&meetingProfileName=Team%20Pro%20Example,%20Pro%20Example&names=Team%20Pro%20Example&names=Pro%20Example&usernames=teampro&usernames=pro */ -export const constructMeetingImage = ({ title, users = [], profile }: MeetingImageProps): string => { +export const constructMeetingImage = async ({ + title, + users = [], + profile, +}: MeetingImageProps): Promise => { const params = new URLSearchParams({ type: "meeting", title, @@ -72,6 +119,8 @@ export const constructMeetingImage = ({ title, users = [], profile }: MeetingIma params.append("usernames", user.username); }); + params.set("v", await getOGImageVersion("meeting")); + return encodeURIComponent(`/api/social/og/image?${params.toString()}`); }; @@ -79,22 +128,34 @@ export const constructMeetingImage = ({ title, users = [], profile }: MeetingIma * Test url: * http://localhost:3000/api/social/og/image?type=app&name=Huddle01&slug=/api/app-store/huddle01video/icon.svg&description=Huddle01%20is%20a%20new%20video%20conferencing%20software%20native%20to%20Web3%20and%20is%20comparable%20to%20a%20decentralized%20version%20of%20Zoom.%20It%20supports%20conversations%20for... */ -export const constructAppImage = ({ name, slug, description }: AppImageProps): string => { +export const constructAppImage = async ({ + name, + slug, + logoUrl, + description, +}: AppImageProps): Promise => { const params = new URLSearchParams({ type: "app", name, slug, description, + logoUrl, }); + + params.set("v", await getOGImageVersion("app")); + return encodeURIComponent(`/api/social/og/image?${params.toString()}`); }; -export const constructGenericImage = ({ title, description }: GenericImageProps) => { +export const constructGenericImage = async ({ title, description }: GenericImageProps): Promise => { const params = new URLSearchParams({ type: "generic", title, description, }); + + params.set("v", await getOGImageVersion("generic")); + return encodeURIComponent(`/api/social/og/image?${params.toString()}`); }; @@ -112,7 +173,14 @@ const Wrapper = ({ children, variant = "light", rotateBackground }: WrapperProps ); +/** + * ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output, + * remember to bump the version in OG_ASSETS.meeting.id (e.g., "meeting-og-image-v1" → "meeting-og-image-v2") + * to ensure proper cache invalidation. + */ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => { + const config = OG_ASSETS.meeting; + // We filter attendees here based on whether they have an image and filter duplicates. // Users ALWAYS have an image (albeit a gray empty person avatar), so this mainly filters out // any non existing images for dynamic collectives, while at the same time removing them from @@ -134,10 +202,10 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => { const names = attendees.length > 0 ? attendees.map((user) => user.name) : [profile.name]; return ( - +
- Logo + Logo {avatars.length > 0 && (
/ @@ -150,12 +218,13 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => { key={avatar} src={avatar} alt="Profile picture" - width="160" - height="160" + width={config.avatarSize} + height={config.avatarSize} /> ))} {avatars.length > 3 && ( -
+
+{avatars.length - 3}
)} @@ -178,10 +247,10 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => { ); }; -const VisualBlur = ({ visualSlug }: { visualSlug: string }) => { +const VisualBlur = ({ logoUrl }: { logoUrl: string }) => { // Making a blur of a dark logo is very ugly. We use the filename to indicate, // when we don't want to render these blurry blob backgrounds. - if (visualSlug.indexOf("dark") > -1) return null; + if (logoUrl.indexOf("dark") > -1) return null; return (
@@ -191,7 +260,7 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => { style={{ filter: "blur(98px)", backgroundColor: "rgba(255, 255, 255, 0.7)", - backgroundImage: `url(${WEBAPP_URL}${visualSlug})`, + backgroundImage: `url(${WEBAPP_URL}${logoUrl})`, backgroundSize: "400px 400px", }} /> @@ -202,7 +271,7 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => { style={{ filter: "blur(150px)", backgroundColor: "rgba(255, 255, 255, 0.7)", - backgroundImage: `url(${WEBAPP_URL}${visualSlug})`, + backgroundImage: `url(${WEBAPP_URL}${logoUrl})`, backgroundSize: "630px 630px", }} /> @@ -210,60 +279,71 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => { ); }; -export const App = ({ name, description, slug }: AppImageProps) => ( - - Logo +/** + * ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output, + * remember to bump the version in OG_ASSETS.app.id (e.g., "app-og-image-v1" → "app-og-image-v2") + * to ensure proper cache invalidation. + */ +export const App = ({ name, description, logoUrl }: AppImageProps) => { + const config = OG_ASSETS.app; - + return ( + + Logo -
-
- App icon -
-
-
-
- {name} -
-
- {description} -
-
-
-); + -export const Generic = ({ title, description }: GenericImageProps) => ( - -
-
- Logo -
- -
-
- {title} +
+
+ App icon
-
+
+
+
+ {name} +
+
{description}
-
- -); + + ); +}; -export const ScreenShot = ({ image, fallbackImage }: ScreenshotImageProps) => ( - -
-
- screenshot - screenshot +/** + * ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output, + * remember to bump the version in OG_ASSETS.generic.id (e.g., "generic-og-image-v1" → "generic-og-image-v2") + * to ensure proper cache invalidation. + */ +export const Generic = ({ title, description }: GenericImageProps) => { + const config = OG_ASSETS.generic; + + return ( + +
+
+ Logo +
+ +
+
+ {title} +
+
+ {description} +
+
-
- -); + + ); +};