Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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=
14 changes: 10 additions & 4 deletions .github/workflows/build-upload-deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const fetchCache = "force-no-store";

import "@/lib/fetch/ssrFetch";
import "@/components/drops/create/lexical/lexical.styles.scss";
import "@/styles/Home.module.scss";
import "@/styles/seize-bootstrap.scss";
Expand Down
10 changes: 10 additions & 0 deletions config/serverEnv.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from "zod";

export const serverEnvSchema = z.object({
SSR_CLIENT_ID: z.string().min(1, "SSR_CLIENT_ID is required"),
SSR_CLIENT_SECRET: z.string().min(1, "SSR_CLIENT_SECRET is required"),
STAGING_API_KEY_SERVER: z.string().optional(),
});

export type ServerEnv = z.infer<typeof serverEnvSchema>;

36 changes: 36 additions & 0 deletions config/serverEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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,
STAGING_API_KEY_SERVER: process.env.STAGING_API_KEY_SERVER,
};

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();
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
37 changes: 37 additions & 0 deletions helpers/server-signature.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createHmac } from "node:crypto";

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"
);
}

const ts = timestamp ?? Math.floor(Date.now() / 1000);
const payload = `${clientId}\n${ts}\n${method}\n${path}`;
const signature = createHmac("sha256", secret).update(payload).digest("hex");

return {
clientId,
timestamp: ts,
signature,
};
}
Comment thread
prxt6529 marked this conversation as resolved.

export function generateWafSignature(clientId: string, secret: string): string {
if (globalThis.window !== undefined) {
throw new TypeError(
"generateWafSignature can only be used on the server side"
);
}

const signature = createHmac("sha256", secret).update(clientId).digest("hex");

return signature;
Comment thread
prxt6529 marked this conversation as resolved.
}
128 changes: 128 additions & 0 deletions lib/fetch/ssrFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { publicEnv } from "@/config/env";
import { getServerEnvOrThrow } from "@/config/serverEnv";
import {
generateClientSignature,
generateWafSignature,
} from "@/helpers/server-signature.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<Response> => {
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;
let stagingApiKey: string | undefined;

try {
const env = getServerEnvOrThrow();
clientId = env.SSR_CLIENT_ID;
clientSecret = env.SSR_CLIENT_SECRET;
stagingApiKey = env.STAGING_API_KEY_SERVER;
} catch {
return originalFetch(input, init);
}

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);
}

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);

if (stagingApiKey) {
enhancedHeaders.set("x-6529-auth", stagingApiKey);
}

return originalFetch(input, {
...init,
headers: enhancedHeaders,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

if (globalThis.window === undefined) {
globalThis.fetch = enhancedFetch;
}

export { enhancedFetch as ssrFetch };
29 changes: 21 additions & 8 deletions proxy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { publicEnv } from "./config/env";
import { API_AUTH_COOKIE } from "./constants";
import {
getHomeFeedRoute,
getMessagesBaseRoute,
getNotificationsRoute,
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: "/" },
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -250,7 +258,10 @@ async function enforceAccessControl(
req: NextRequest,
normalizedPathname: string
): Promise<NextResponse> {
if (normalizedPathname === "/access" || normalizedPathname === "/restricted") {
if (
normalizedPathname === "/access" ||
normalizedPathname === "/restricted"
) {
return NextResponse.next();
}

Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading