diff --git a/.env.sample b/.env.sample index 3436cce1eb..b55dd4b86c 100644 --- a/.env.sample +++ b/.env.sample @@ -42,3 +42,8 @@ NEXT_PUBLIC_AWS_RUM_IDENTITY_POOL_ID= PEPE_CACHE_TTL_MINUTES=10 PEPE_CACHE_MAX_ITEMS=500 IPFS_GATEWAY=https://ipfs.io/ipfs/ + +# SERVER-SIDE CLIENT IDENTIFICATION (for SSR requests) +# Used to sign server-side API requests with HMAC-SHA256 +SSR_CLIENT_ID= +SSR_CLIENT_SECRET= diff --git a/.github/workflows/build-upload-deploy-prod.yml b/.github/workflows/build-upload-deploy-prod.yml index bfb9f4d3dc..73dba2ce29 100644 --- a/.github/workflows/build-upload-deploy-prod.yml +++ b/.github/workflows/build-upload-deploy-prod.yml @@ -80,12 +80,18 @@ jobs: --version-label "${{ env.COMMIT_SHA }}" \ --description "${{ env.COMMIT_MESSAGE }}" - - name: Deploy new Version to ElasticBeanstalk + - name: Deploy new Version to ElasticBeanstalk with Runtime Environment Variables + env: + SSR_CLIENT_ID: ${{ secrets.SSR_CLIENT_ID }} + SSR_CLIENT_SECRET: ${{ secrets.SSR_CLIENT_SECRET }} run: | aws elasticbeanstalk update-environment \ - --application-name "${{ env.BEANSTALK_APP_NAME }}" \ - --environment-name "${{ env.BEANSTALK_ENV_NAME }}" \ - --version-label "${{ env.COMMIT_SHA }}" + --application-name "${{ env.BEANSTALK_APP_NAME }}" \ + --environment-name "${{ env.BEANSTALK_ENV_NAME }}" \ + --version-label "${{ env.COMMIT_SHA }}" \ + --option-settings \ + "Namespace=aws:elasticbeanstalk:application:environment,OptionName=SSR_CLIENT_ID,Value=${SSR_CLIENT_ID}" \ + "Namespace=aws:elasticbeanstalk:application:environment,OptionName=SSR_CLIENT_SECRET,Value=${SSR_CLIENT_SECRET}" - name: Check Elastic Beanstalk health and readiness (120s warmup then 20 retries with 60s delay) run: | diff --git a/__tests__/app/page.test.tsx b/__tests__/app/page.test.tsx index 9443e25872..7df5980d1e 100644 --- a/__tests__/app/page.test.tsx +++ b/__tests__/app/page.test.tsx @@ -24,9 +24,8 @@ jest.mock("@/components/latest-activity/LatestActivity", () => // Mock API fetch to prevent network calls -jest.mock("@/services/6529api", () => ({ - fetchUrl: jest.fn().mockResolvedValue({ data: [] }), - fetchAllPages: jest.fn().mockResolvedValue([]), +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn().mockResolvedValue({ data: [], count: 0 }), })); diff --git a/__tests__/services/common-api.more.test.ts b/__tests__/services/common-api.more.test.ts index 922673c0ab..cfa6e8f298 100644 --- a/__tests__/services/common-api.more.test.ts +++ b/__tests__/services/common-api.more.test.ts @@ -20,7 +20,9 @@ describe("commonApi utility methods", () => { it("commonApiPut posts JSON body", async () => { (global.fetch as jest.Mock).mockResolvedValue({ ok: true, + status: 200, json: async () => ({ ok: 1 }), + headers: new Headers({ "content-type": "application/json" }), }); const res = await commonApiPut({ endpoint: "e", body: { a: 1 } }); expect(res).toEqual({ ok: 1 }); @@ -39,9 +41,11 @@ describe("commonApi utility methods", () => { }); it("commonApiDeleteWithBody deletes with body", async () => { - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, + status: 200, json: async () => ({ r: 2 }), + headers: new Headers({ "content-type": "application/json" }), }); const res = await commonApiDeleteWithBody({ endpoint: "del", @@ -63,7 +67,11 @@ describe("commonApi utility methods", () => { }); it("commonApiDelete sends DELETE request", async () => { - (global.fetch as jest.Mock).mockResolvedValue({ ok: true }); + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + }); await commonApiDelete({ endpoint: "x" }); expect(globalThis.fetch).toHaveBeenCalledWith( "https://api.test.6529.io/api/x", @@ -82,7 +90,9 @@ describe("commonApi utility methods", () => { const form = new FormData(); (global.fetch as jest.Mock).mockResolvedValue({ ok: true, + status: 200, json: async () => ({ res: 3 }), + headers: new Headers({ "content-type": "application/json" }), }); const { commonApiPostForm } = await import("@/services/api/common-api"); const result = await commonApiPostForm({ endpoint: "f", body: form }); diff --git a/__tests__/services/common-api.postNoBody.test.ts b/__tests__/services/common-api.postNoBody.test.ts index 6b164d3336..f2ee249573 100644 --- a/__tests__/services/common-api.postNoBody.test.ts +++ b/__tests__/services/common-api.postNoBody.test.ts @@ -32,11 +32,12 @@ describe("commonApiPostWithoutBodyAndResponse", () => { it("rejects with error body", async () => { (global.fetch as jest.Mock).mockResolvedValue({ ok: false, + status: 400, statusText: "x", json: async () => ({ error: "err" }), }); await expect( commonApiPostWithoutBodyAndResponse({ endpoint: "e" }) - ).rejects.toBe("err"); + ).rejects.toThrow("HTTP 400 x: err"); }); }); diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index db19f82b5f..13871a98e0 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -35,7 +35,7 @@ describe("commonApiFetch", () => { Authorization: "Bearer jwt", }), signal: undefined, - }), + }) ); expect(result).toEqual({ result: 1 }); }); @@ -45,17 +45,20 @@ describe("commonApiFetch", () => { (getAuthJwt as jest.Mock).mockReturnValue(null); fetchMock.mockResolvedValue({ ok: false, + status: 400, statusText: "Bad", json: async () => ({ error: "err" }), }); - await expect(commonApiFetch({ endpoint: "bad" })).rejects.toBe("err"); + await expect(commonApiFetch({ endpoint: "bad" })).rejects.toThrow( + "HTTP 400 Bad: err" + ); expect(fetchMock).toHaveBeenCalledWith( "https://api.test.6529.io/api/bad", expect.objectContaining({ headers: {}, signal: undefined, - }), + }) ); }); }); @@ -87,7 +90,7 @@ describe("commonApiPost", () => { "x-6529-auth": "a", }), body: JSON.stringify({ v: 1 }), - }), + }) ); expect(result).toEqual({ res: 1 }); }); @@ -97,12 +100,13 @@ describe("commonApiPost", () => { (getAuthJwt as jest.Mock).mockReturnValue(null); fetchMock.mockResolvedValue({ ok: false, + status: 400, statusText: "B", json: async () => ({ error: "err" }), }); - await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( - "err", + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toThrow( + "HTTP 400 B: err" ); expect(fetchMock).toHaveBeenCalledWith( "https://api.test.6529.io/api/e", @@ -112,7 +116,7 @@ describe("commonApiPost", () => { "Content-Type": "application/json", }), body: JSON.stringify({}), - }), + }) ); }); }); diff --git a/app/layout.tsx b/app/layout.tsx index 5523aa97d6..e06fa98d1a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,8 @@ export const fetchCache = "force-no-store"; +// Side effect: Overrides globalThis.fetch on server-side to automatically +// add auth headers (x-6529-internal-*) for rate limiter/WAF bypass +import "@/lib/fetch/ssrFetch"; import "@/components/drops/create/lexical/lexical.styles.scss"; import "@/styles/Home.module.scss"; import "@/styles/seize-bootstrap.scss"; diff --git a/components/latest-activity/LatestActivity.tsx b/components/latest-activity/LatestActivity.tsx index 0ea41ff1db..c907697ce3 100644 --- a/components/latest-activity/LatestActivity.tsx +++ b/components/latest-activity/LatestActivity.tsx @@ -1,7 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; -import { Container, Row } from "react-bootstrap"; import { NFT } from "@/entities/INFT"; import { NextGenCollection } from "@/entities/INextgen"; import { Transaction } from "@/entities/ITransaction"; @@ -9,6 +7,8 @@ import useIsMobileScreen from "@/hooks/isMobileScreen"; import { useActivityData } from "@/hooks/useActivityData"; import { useActivityFilters } from "@/hooks/useActivityFilters"; import { useNFTCollections } from "@/hooks/useNFTCollections"; +import { useEffect, useState } from "react"; +import { Container, Row } from "react-bootstrap"; import Pagination from "../pagination/Pagination"; import ActivityFilters from "./ActivityFilters"; import ActivityHeader from "./ActivityHeader"; diff --git a/components/latest-activity/fetchInitialActivityData.ts b/components/latest-activity/fetchInitialActivityData.ts index 92b93d033b..7def87f16f 100644 --- a/components/latest-activity/fetchInitialActivityData.ts +++ b/components/latest-activity/fetchInitialActivityData.ts @@ -1,9 +1,7 @@ -import { publicEnv } from "@/config/env"; import { DBResponse } from "@/entities/IDBResponse"; import { NFT } from "@/entities/INFT"; import { NextGenCollection } from "@/entities/INextgen"; import { Transaction } from "@/entities/ITransaction"; -import { fetchAllPages, fetchUrl } from "@/services/6529api"; import { commonApiFetch } from "@/services/api/common-api"; export interface InitialActivityData { @@ -18,38 +16,43 @@ export async function fetchInitialActivityData( pageSize: number = 50 ): Promise { try { - // Build activity API URL with default filters (All/All) - const activityUrl = `${publicEnv.API_ENDPOINT}/api/transactions?page_size=${pageSize}&page=${page}`; - // Fetch all data in parallel - const [activityResponse, memesResponse, gradientsData, nextgenResponse] = - await Promise.all([ - // Activity data - fetchUrl(activityUrl) as Promise, + const [ + activityResponse, + memesResponse, + gradientsResponse, + nextgenResponse, + ] = await Promise.all([ + // Activity data + commonApiFetch({ + endpoint: "transactions", + params: { + page_size: String(pageSize), + page: String(page), + }, + }), - // Memes data - fetchUrl( - `${publicEnv.API_ENDPOINT}/api/memes_lite` - ) as Promise, + // Memes data + commonApiFetch>({ + endpoint: "memes_lite", + }), - // Gradients data - fetchAllPages( - `${publicEnv.API_ENDPOINT}/api/nfts/gradients?&page_size=101` - ), + // Gradients data (first page only, page_size 101) + commonApiFetch>({ + endpoint: "nfts/gradients", + params: { + page_size: "101", + }, + }), - // NextGen collections - commonApiFetch<{ - count: number; - page: number; - next: any; - data: NextGenCollection[]; - }>({ - endpoint: `nextgen/collections`, - }), - ]); + // NextGen collections + commonApiFetch>({ + endpoint: `nextgen/collections`, + }), + ]); // Combine memes and gradients - const nfts = [...memesResponse.data, ...gradientsData]; + const nfts = [...memesResponse.data, ...gradientsResponse.data]; return { activity: activityResponse.data, diff --git a/components/layout/SmallScreenHeader.tsx b/components/layout/SmallScreenHeader.tsx index 173a570f0e..03eea4d445 100644 --- a/components/layout/SmallScreenHeader.tsx +++ b/components/layout/SmallScreenHeader.tsx @@ -1,10 +1,9 @@ "use client"; -import React from "react"; -import Link from "next/link"; -import Image from "next/image"; -import { Bars3Icon } from "@heroicons/react/24/outline"; import HeaderSearchButton from "@/components/header/header-search/HeaderSearchButton"; +import { Bars3Icon } from "@heroicons/react/24/outline"; +import Image from "next/image"; +import Link from "next/link"; interface SmallScreenHeaderProps { readonly onMenuToggle: () => void; @@ -24,7 +23,7 @@ export default function SmallScreenHeader({ loading="eager" priority alt="6529Seize" - src="/6529.png" + src="/6529.svg" className="tw-h-10 tw-w-10 tw-flex-shrink-0 tw-transition-all tw-duration-100 desktop-hover:hover:tw-scale-[1.02] desktop-hover:hover:tw-shadow-[0_0_20px_10px_rgba(255,215,215,0.3)]" width={40} height={40} @@ -35,8 +34,7 @@ export default function SmallScreenHeader({ diff --git a/components/layout/SmallScreenLayout.tsx b/components/layout/SmallScreenLayout.tsx index da29d692b7..9f2d22073f 100644 --- a/components/layout/SmallScreenLayout.tsx +++ b/components/layout/SmallScreenLayout.tsx @@ -1,20 +1,13 @@ "use client"; -import React, { - ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import SmallScreenHeader from "./SmallScreenHeader"; -import WebSidebar from "./sidebar/WebSidebar"; -import { SIDEBAR_WIDTHS } from "../../constants/sidebar"; -import { useLayout } from "../brain/my-stream/layout/LayoutContext"; +import { useHeaderContext } from "@/contexts/HeaderContext"; import { useSearchParams } from "next/navigation"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { SIDEBAR_WIDTHS } from "../../constants/sidebar"; import { SidebarProvider } from "../../hooks/useSidebarState"; -import ClientOnly from "../client-only/ClientOnly"; -import { useHeaderContext } from "@/contexts/HeaderContext"; +import { useLayout } from "../brain/my-stream/layout/LayoutContext"; +import SmallScreenHeader from "./SmallScreenHeader"; +import WebSidebar from "./sidebar/WebSidebar"; interface Props { readonly children: ReactNode; @@ -63,54 +56,33 @@ export default function SmallScreenLayout({ children }: Props) { return ( - -
- Brain -

- Loading... -

-
- - } - > -
-
- -
- -
- -
+
+
+ +
-
- {children} -
+
+
- + +
+ {children} +
+
); } diff --git a/components/layout/WebLayout.tsx b/components/layout/WebLayout.tsx index e9b2e8d230..f680f1ed7a 100644 --- a/components/layout/WebLayout.tsx +++ b/components/layout/WebLayout.tsx @@ -1,11 +1,9 @@ "use client"; -import Image from "next/image"; import React, { type ReactNode, useMemo } from "react"; import { SIDEBAR_WIDTHS } from "../../constants/sidebar"; import { useSidebarController } from "../../hooks/useSidebarController"; import { SidebarProvider, useSidebarState } from "../../hooks/useSidebarState"; -import ClientOnly from "../client-only/ClientOnly"; import WebSidebar from "./sidebar/WebSidebar"; const DESKTOP_MAX_WIDTH = 1324; @@ -72,28 +70,7 @@ const WebLayoutContent = ({ children, isSmall = false }: WebLayoutProps) => { const WebLayout = ({ children, isSmall = false }: WebLayoutProps) => ( - -
- Brain -

- Loading... -

-
-
- }> - {children} -
+ {children}
); diff --git a/components/layout/sidebar/WebSidebarHeader.tsx b/components/layout/sidebar/WebSidebarHeader.tsx index 11f13c5a69..e2c7b5a304 100644 --- a/components/layout/sidebar/WebSidebarHeader.tsx +++ b/components/layout/sidebar/WebSidebarHeader.tsx @@ -1,8 +1,8 @@ "use client"; -import Link from "next/link"; -import Image from "next/image"; import { ChevronDoubleLeftIcon } from "@heroicons/react/24/outline"; +import Image from "next/image"; +import Link from "next/link"; interface WebSidebarHeaderProps { readonly collapsed: boolean; @@ -14,14 +14,13 @@ function WebSidebarHeader({ collapsed, onToggle }: WebSidebarHeaderProps) {
+ className="tw-relative tw-z-10 tw-flex tw-items-center tw-ml-3 tw-ease-in-out"> 6529Seize + aria-label="Toggle right sidebar"> ; diff --git a/config/serverEnv.ts b/config/serverEnv.ts new file mode 100644 index 0000000000..3b1692b9ee --- /dev/null +++ b/config/serverEnv.ts @@ -0,0 +1,35 @@ +import { serverEnvSchema, type ServerEnv } from "./serverEnv.schema"; + +if (globalThis.window !== undefined) { + throw new TypeError("serverEnv can only be accessed on the server side"); +} + +const getServerEnv = (): ServerEnv | null => { + if (typeof process === "undefined" || !process.env) { + return null; + } + + const raw = { + SSR_CLIENT_ID: process.env.SSR_CLIENT_ID, + SSR_CLIENT_SECRET: process.env.SSR_CLIENT_SECRET, + }; + + const parsed = serverEnvSchema.safeParse(raw); + if (!parsed.success) { + return null; + } + + return parsed.data; +}; + +export const getServerEnvOrThrow = (): ServerEnv => { + const env = getServerEnv(); + if (!env) { + throw new Error( + "SSR_CLIENT_ID and SSR_CLIENT_SECRET must be set as runtime environment variables" + ); + } + return env; +}; + +export const serverEnv: ServerEnv | null = getServerEnv(); diff --git a/entities/IDBResponse.ts b/entities/IDBResponse.ts index 110ee5bef9..de3d485bb2 100644 --- a/entities/IDBResponse.ts +++ b/entities/IDBResponse.ts @@ -1,8 +1,8 @@ -export interface DBResponse { +export interface DBResponse { count: number; page: number; next: any; - data: any[]; + data: T[]; } interface LeaderboardDBResponse { diff --git a/eslint.config.mjs b/eslint.config.mjs index 13e8c89124..c794e19b3d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -85,6 +85,7 @@ export default defineConfig([globalIgnores([ "scripts/**", "**/next.config.*", "config/env.ts", + "config/serverEnv.ts", "__tests__/config/env.base-endpoint.test.ts", "**/playwright.config.ts", "tests/**", diff --git a/helpers/server-signature.helpers.ts b/helpers/server-signature.helpers.ts new file mode 100644 index 0000000000..60bdb10e67 --- /dev/null +++ b/helpers/server-signature.helpers.ts @@ -0,0 +1,101 @@ +import { createHmac } from "node:crypto"; + +/** + * Generates a client signature for server-side authentication. + * + * The signature scheme uses HMAC-SHA256 to create a cryptographic signature + * from a payload containing the client ID, timestamp, HTTP method, and path. + * The timestamp is used to prevent replay attacks within a configurable time window + * (typically 300 seconds / 5 minutes). The signature must match exactly on both + * client and server for authentication to succeed. + * + * @param clientId - The client identifier (must be a non-empty string) + * @param secret - The shared secret key for HMAC signing (must be a non-empty string) + * @param method - The HTTP method (will be normalized to uppercase, must be non-empty) + * @param path - The request path including query string if present (pathname + search, e.g., '/api/users?page=1'; must be non-empty) + * @param timestamp - Optional Unix timestamp in seconds. If not provided, uses current time. + * @returns An object containing the clientId, timestamp, and generated signature + * @throws {TypeError} If called in a browser environment or if any required parameter is invalid + * + * @example + * ```ts + * const { signature } = generateClientSignature( + * 'my-client-id', + * 'my-secret', + * 'POST', + * '/api/users?page=1' + * ); + * ``` + */ +export function generateClientSignature( + clientId: string, + secret: string, + method: string, + path: string, + timestamp?: number +): { clientId: string; timestamp: number; signature: string } { + if (globalThis.window !== undefined) { + throw new TypeError( + "generateClientSignature can only be used on the server side" + ); + } + + if (typeof clientId !== "string" || clientId.trim().length === 0) { + throw new TypeError( + "generateClientSignature: clientId must be a non-empty string" + ); + } + + if (typeof secret !== "string" || secret.trim().length === 0) { + throw new TypeError( + "generateClientSignature: secret must be a non-empty string" + ); + } + + if (typeof method !== "string" || method.trim().length === 0) { + throw new TypeError( + "generateClientSignature: method must be a non-empty string" + ); + } + + if (typeof path !== "string" || path.trim().length === 0) { + throw new TypeError( + "generateClientSignature: path must be a non-empty string" + ); + } + + const normalizedMethod = method.toUpperCase(); + const ts = timestamp ?? Math.floor(Date.now() / 1000); + const payload = `${clientId}\n${ts}\n${normalizedMethod}\n${path}`; + const signature = createHmac("sha256", secret).update(payload).digest("hex"); + + return { + clientId, + timestamp: ts, + signature, + }; +} + +export function generateWafSignature(clientId: string, secret: string): string { + if (globalThis.window !== undefined) { + throw new TypeError( + "generateWafSignature can only be used on the server side" + ); + } + + if (typeof clientId !== "string" || clientId.trim().length === 0) { + throw new TypeError( + "generateWafSignature: clientId must be a non-empty string" + ); + } + + if (typeof secret !== "string" || secret.trim().length === 0) { + throw new TypeError( + "generateWafSignature: secret must be a non-empty string" + ); + } + + const signature = createHmac("sha256", secret).update(clientId).digest("hex"); + + return signature; +} diff --git a/lib/fetch/ssrFetch.ts b/lib/fetch/ssrFetch.ts new file mode 100644 index 0000000000..07a898be2d --- /dev/null +++ b/lib/fetch/ssrFetch.ts @@ -0,0 +1,143 @@ +import { publicEnv } from "@/config/env"; +import { getServerEnvOrThrow } from "@/config/serverEnv"; +import { + generateClientSignature, + generateWafSignature, +} from "@/helpers/server-signature.helpers"; +import { getAppCommonHeaders } from "@/helpers/server.app.helpers"; + +const getOriginalFetch = (): typeof fetch => { + if (globalThis.fetch === undefined) { + throw new TypeError( + "fetch not available in current runtime. This module requires a fetch implementation." + ); + } + if (typeof globalThis.fetch !== "function") { + throw new TypeError( + "fetch is not a function in current runtime. Expected a function but got a different type." + ); + } + return globalThis.fetch; +}; + +const originalFetch = getOriginalFetch(); + +const isApiRequest = (url: string | URL): boolean => { + const urlString = typeof url === "string" ? url : url.toString(); + try { + const parsedUrl = new URL(urlString, publicEnv.API_ENDPOINT); + return parsedUrl.origin === new URL(publicEnv.API_ENDPOINT).origin; + } catch { + return false; + } +}; + +const extractPathFromUrl = (url: string | URL, baseUrl: string): string => { + const urlString = typeof url === "string" ? url : url.toString(); + try { + const parsedUrl = new URL(urlString, baseUrl); + return parsedUrl.pathname + parsedUrl.search; + } catch { + return ""; + } +}; + +const enhancedFetch: typeof fetch = async ( + input: RequestInfo | URL, + init?: RequestInit +): Promise => { + if (globalThis.window !== undefined) { + return originalFetch(input, init); + } + + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else { + url = input.url; + } + + if (!isApiRequest(url)) { + return originalFetch(input, init); + } + + let clientId: string; + let clientSecret: string; + + const method = ( + init?.method ?? + (input instanceof Request ? input.method : undefined) ?? + "GET" + ).toUpperCase(); + const path = extractPathFromUrl(url, publicEnv.API_ENDPOINT); + + if (!path) { + return originalFetch(input, init); + } + + try { + const env = getServerEnvOrThrow(); + clientId = env.SSR_CLIENT_ID; + clientSecret = env.SSR_CLIENT_SECRET; + } catch { + console.warn( + `[SSR Fetch] [PATH: ${path}] SSR credentials unavailable, falling back to unauthenticated fetch. Internal rate limits will not be bypassed.` + ); + return originalFetch(input, init); + } + + const signatureData = generateClientSignature( + clientId, + clientSecret, + method, + path + ); + + const wafSignature = generateWafSignature(clientId, clientSecret); + + const baseHeaders = input instanceof Request ? input.headers : undefined; + const enhancedHeaders = new Headers(baseHeaders); + if (init?.headers) { + new Headers(init.headers).forEach((value, key) => { + enhancedHeaders.set(key, value); + }); + } + + enhancedHeaders.set("x-6529-internal-id", signatureData.clientId); + enhancedHeaders.set("x-6529-internal-signature", signatureData.signature); + enhancedHeaders.set( + "x-6529-internal-timestamp", + signatureData.timestamp.toString() + ); + enhancedHeaders.set("x-6529-internal-waf-signature", wafSignature); + + try { + const appHeaders = await getAppCommonHeaders(); + Object.entries(appHeaders).forEach(([key, value]) => { + enhancedHeaders.set(key, value); + }); + } catch (error) { + console.warn( + `[SSR Fetch] [PATH: ${path}] Failed to get app common headers:`, + error instanceof Error ? error.message : error + ); + } + + const headerKeys = Array.from(enhancedHeaders.keys()); + console.log( + `[SSR Fetch] [PATH: ${path}] Request headers: ${headerKeys.join(", ")}` + ); + + return originalFetch(input, { + ...init, + headers: enhancedHeaders, + }); +}; + +if (globalThis.window === undefined) { + globalThis.fetch = enhancedFetch; +} + +export { enhancedFetch as ssrFetch }; diff --git a/proxy.ts b/proxy.ts index 851b9a4934..27c3f5adbd 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,6 +1,3 @@ -import { NextRequest, NextResponse } from "next/server"; -import { publicEnv } from "./config/env"; -import { API_AUTH_COOKIE } from "./constants"; import { getHomeFeedRoute, getMessagesBaseRoute, @@ -8,6 +5,9 @@ import { getWaveRoute, getWavesBaseRoute, } from "@/helpers/navigation.helpers"; +import { NextRequest, NextResponse } from "next/server"; +import { publicEnv } from "./config/env"; +import { API_AUTH_COOKIE } from "./constants"; const redirectMappings = [ { url: "/6529-dubai/", target: "/" }, @@ -74,7 +74,13 @@ const redirectMappings = [ { url: "/om/bug/report/", target: "/about/contact-us" }, ]; -const STATIC_PATH_PREFIXES = ["/api", "/_next", "/sitemap", "/robots.txt", "/error"] as const; +const STATIC_PATH_PREFIXES = [ + "/api", + "/_next", + "/sitemap", + "/robots.txt", + "/error", +] as const; const STATIC_PATH_SUFFIXES = [ "favicon.ico", ".jpeg", @@ -112,7 +118,9 @@ function isDesktopOSFromUserAgent(userAgent: string): boolean { userAgent.includes("x11") || userAgent.includes("cros"); - return !isAndroid && !isIOS && (hasDesktopSignal || isMacDesktop || isLinuxDesktop); + return ( + !isAndroid && !isIOS && (hasDesktopSignal || isMacDesktop || isLinuxDesktop) + ); } function normalizeDropParam(value: string | null): string | undefined { @@ -250,7 +258,10 @@ async function enforceAccessControl( req: NextRequest, normalizedPathname: string ): Promise { - if (normalizedPathname === "/access" || normalizedPathname === "/restricted") { + if ( + normalizedPathname === "/access" || + normalizedPathname === "/restricted" + ) { return NextResponse.next(); } @@ -302,8 +313,10 @@ export default async function proxy(req: NextRequest) { } if ( - STATIC_PATH_PREFIXES.some(prefix => normalizedPathname.startsWith(prefix)) || - STATIC_PATH_SUFFIXES.some(suffix => normalizedPathname.endsWith(suffix)) + STATIC_PATH_PREFIXES.some((prefix) => + normalizedPathname.startsWith(prefix) + ) || + STATIC_PATH_SUFFIXES.some((suffix) => normalizedPathname.endsWith(suffix)) ) { return NextResponse.next(); } diff --git a/services/api/common-api.ts b/services/api/common-api.ts index cb606e54cb..481f947368 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -15,35 +15,102 @@ const getHeaders = ( }; }; -export const commonApiFetch = async >(param: { - endpoint: string; - headers?: Record; - params?: U; - signal?: AbortSignal; -}): Promise => { - let url = `${publicEnv.API_ENDPOINT}/api/${param.endpoint}`; - if (param.params) { +const buildUrl = ( + endpoint: string, + params?: Record, + transformParams?: (params: Record) => Record +): string => { + let path = `/api/${endpoint}`; + let url = `${publicEnv.API_ENDPOINT}${path}`; + + if (params) { const queryParams = new URLSearchParams(); - // Override NIC with CIC - Object.entries(param.params).forEach(([key, value]: [string, any]) => { - const newValue = value === "nic" ? "cic" : value; - queryParams.set(key, newValue); + const processedParams = transformParams ? transformParams(params) : params; + Object.entries(processedParams).forEach(([key, value]) => { + queryParams.set(key, value); }); - url += `?${queryParams.toString()}`; + const queryString = queryParams.toString(); + url += `?${queryString}`; } + + return url; +}; + +const handleApiError = async (res: Response): Promise => { + let errorMessage: string; + let rawContent: string = ""; + + try { + const body: any = await res.json(); + errorMessage = body?.error ?? res.statusText ?? "Something went wrong"; + } catch { + try { + rawContent = await res.text(); + errorMessage = rawContent || res.statusText || "Something went wrong"; + } catch { + errorMessage = res.statusText || "Something went wrong"; + } + } + + const statusPart = res.status ? `HTTP ${res.status}` : "HTTP Error"; + const statusTextPart = res.statusText ? ` ${res.statusText}` : ""; + const composedError = `${statusPart}${statusTextPart}: ${errorMessage}`; + throw new Error(composedError); +}; + +const executeApiRequest = async ( + url: string, + method: string, + headers: Record, + body?: BodyInit, + signal?: AbortSignal, + parseJson: boolean = true +): Promise => { const res = await fetch(url, { - headers: getHeaders(param.headers, false), - signal: param.signal, + method, + headers, + body, + signal, }); + if (!res.ok) { - const body: any = await res.json(); - return new Promise((_, rej) => - rej(body?.error ?? res.statusText ?? "Something went wrong") - ); + return handleApiError(res); + } + + if (!parseJson) { + return undefined as T; } + return res.json(); }; +export const commonApiFetch = async >(param: { + endpoint: string; + headers?: Record; + params?: U; + signal?: AbortSignal; +}): Promise => { + const url = buildUrl( + param.endpoint, + param.params as Record | undefined, + (params) => { + const transformed: Record = {}; + Object.entries(params).forEach(([key, value]) => { + transformed[key] = value === "nic" ? "cic" : value; + }); + return transformed; + } + ); + + return executeApiRequest( + url, + "GET", + getHeaders(param.headers, false), + undefined, + param.signal + ); +}; + interface RetryOptions { /** Maximum number of retry attempts. Default: 0 (no retries). */ readonly maxRetries?: number; @@ -170,58 +237,50 @@ export const commonApiPost = async >(param: { params?: Z; signal?: AbortSignal; }): Promise => { - let url = `${publicEnv.API_ENDPOINT}/api/${param.endpoint}`; - if (param.params) { - const queryParams = new URLSearchParams(param.params); - url += `?${queryParams.toString()}`; - } - const res = await fetch(url, { - method: "POST", - headers: getHeaders(param.headers), - body: JSON.stringify(param.body), - signal: param.signal, - }); - if (!res.ok) { - const body: any = await res.json(); - return new Promise((_, rej) => - rej(body?.error ?? res.statusText ?? "Something went wrong") - ); - } - return res.json(); + const url = buildUrl( + param.endpoint, + param.params as Record | undefined + ); + + return executeApiRequest( + url, + "POST", + getHeaders(param.headers, true), + JSON.stringify(param.body), + param.signal + ); }; export const commonApiPostWithoutBodyAndResponse = async (param: { endpoint: string; headers?: Record; }): Promise => { - let url = `${publicEnv.API_ENDPOINT}/api/${param.endpoint}`; - const res = await fetch(url, { - method: "POST", - headers: getHeaders(param.headers), - body: "", - }); - if (!res.ok) { - const body: any = await res.json(); - return new Promise((_, rej) => - rej(body?.error ?? res.statusText ?? "Something went wrong") - ); - } + const url = buildUrl(param.endpoint); + + await executeApiRequest( + url, + "POST", + getHeaders(param.headers, true), + "", + undefined, + false + ); }; export const commonApiDelete = async (param: { endpoint: string; headers?: Record; }): Promise => { - const res = await fetch(`${publicEnv.API_ENDPOINT}/api/${param.endpoint}`, { - method: "DELETE", - headers: getHeaders(param.headers), - }); - if (!res.ok) { - const body: any = await res.json(); - return Promise.reject( - new Error(body?.error ?? res.statusText ?? "Something went wrong") - ); - } + const url = buildUrl(param.endpoint); + + await executeApiRequest( + url, + "DELETE", + getHeaders(param.headers), + undefined, + undefined, + false + ); }; export const commonApiDeleteWithBody = async < @@ -234,23 +293,17 @@ export const commonApiDeleteWithBody = async < headers?: Record; params?: Z; }): Promise => { - let url = `${publicEnv.API_ENDPOINT}/api/${param.endpoint}`; - if (param.params) { - const queryParams = new URLSearchParams(param.params); - url += `?${queryParams.toString()}`; - } - const res = await fetch(url, { - method: "DELETE", - headers: getHeaders(param.headers), - body: JSON.stringify(param.body), - }); - if (!res.ok) { - const body: any = await res.json(); - return Promise.reject( - body?.error ?? res.statusText ?? "Something went wrong" - ); - } - return res.json(); + const url = buildUrl( + param.endpoint, + param.params as Record | undefined + ); + + return executeApiRequest( + url, + "DELETE", + getHeaders(param.headers, true), + JSON.stringify(param.body) + ); }; export const commonApiPut = async >(param: { @@ -259,23 +312,17 @@ export const commonApiPut = async >(param: { headers?: Record; params?: Z; }): Promise => { - let url = `${publicEnv.API_ENDPOINT}/api/${param.endpoint}`; - if (param.params) { - const queryParams = new URLSearchParams(param.params); - url += `?${queryParams.toString()}`; - } - const res = await fetch(url, { - method: "PUT", - headers: getHeaders(param.headers), - body: JSON.stringify(param.body), - }); - if (!res.ok) { - const body: any = await res.json(); - return Promise.reject( - body?.error ?? res.statusText ?? "Something went wrong" - ); - } - return res.json(); + const url = buildUrl( + param.endpoint, + param.params as Record | undefined + ); + + return executeApiRequest( + url, + "PUT", + getHeaders(param.headers, true), + JSON.stringify(param.body) + ); }; export const commonApiPostForm = async (param: { @@ -283,16 +330,12 @@ export const commonApiPostForm = async (param: { body: FormData; headers?: Record; }): Promise => { - const res = await fetch(`${publicEnv.API_ENDPOINT}/api/${param.endpoint}`, { - method: "POST", - headers: getHeaders(param.headers, false), - body: param.body, - }); - if (!res.ok) { - const body: any = await res.json(); - return new Promise((_, rej) => - rej(body?.error ?? res.statusText ?? "Something went wrong") - ); - } - return res.json(); + const url = buildUrl(param.endpoint); + + return executeApiRequest( + url, + "POST", + getHeaders(param.headers, false), + param.body + ); };