diff --git a/apps/partner-sas/app/ClientComponent.tsx b/apps/partner-sas/app/[lang]/ClientComponent.tsx similarity index 100% rename from apps/partner-sas/app/ClientComponent.tsx rename to apps/partner-sas/app/[lang]/ClientComponent.tsx diff --git a/apps/partner-sas/app/layout.tsx b/apps/partner-sas/app/[lang]/layout.tsx similarity index 85% rename from apps/partner-sas/app/layout.tsx rename to apps/partner-sas/app/[lang]/layout.tsx index 7919902db..cc501de95 100644 --- a/apps/partner-sas/app/layout.tsx +++ b/apps/partner-sas/app/[lang]/layout.tsx @@ -1,14 +1,13 @@ import "@scandic-hotels/design-system/style.css" import "@scandic-hotels/design-system/fonts.css" import "@/public/_static/css/design-system-new-deprecated.css" -import "./globals.css" import { Lang } from "@scandic-hotels/common/constants/language" import { TrpcProvider } from "@scandic-hotels/trpc/Provider" -import { getMessages } from "../i18n" -import ClientIntlProvider from "../i18n/Provider" -import { setLang } from "../i18n/serverContext" +import { getMessages } from "@/i18n" +import ClientIntlProvider from "@/i18n/Provider" +import { setLang } from "@/i18n/serverContext" import type { Metadata } from "next" @@ -27,8 +26,7 @@ type RootLayoutProps = { } export default async function RootLayout(props: RootLayoutProps) { - // const params = await props.params - const params = { lang: Lang.sv } + const params = await props.params const { children } = props diff --git a/apps/partner-sas/app/[lang]/middleware-error/404/page.tsx b/apps/partner-sas/app/[lang]/middleware-error/404/page.tsx new file mode 100644 index 000000000..d61fa34cb --- /dev/null +++ b/apps/partner-sas/app/[lang]/middleware-error/404/page.tsx @@ -0,0 +1,4 @@ +export default function NotFoundPage() { + // eslint-disable-next-line formatjs/no-literal-string-in-jsx + return
Not found, forgot lang in url?
+} diff --git a/apps/partner-sas/app/[lang]/middleware-error/[status]/page.module.css b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.module.css new file mode 100644 index 000000000..7f039aed6 --- /dev/null +++ b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.module.css @@ -0,0 +1,3 @@ +.layout { + font-family: var(--typography-Body-Regular-fontFamily); +} diff --git a/apps/partner-sas/app/[lang]/middleware-error/[status]/page.tsx b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.tsx new file mode 100644 index 000000000..13ff1aa2e --- /dev/null +++ b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.tsx @@ -0,0 +1,16 @@ +import styles from "./page.module.css" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export default async function MiddlewareError(props: { + params: Promise<{ lang: Lang; status: string }> +}) { + const params = await props.params + + return ( + // eslint-disable-next-line formatjs/no-literal-string-in-jsx +
+ Middleware error {params.lang} {params.status} +
+ ) +} diff --git a/apps/partner-sas/app/[lang]/not-found.tsx b/apps/partner-sas/app/[lang]/not-found.tsx new file mode 100644 index 000000000..bca1006c4 --- /dev/null +++ b/apps/partner-sas/app/[lang]/not-found.tsx @@ -0,0 +1,4 @@ +export default function NotFoundPage() { + // eslint-disable-next-line formatjs/no-literal-string-in-jsx + return
Not Found, missing lang in url?
+} diff --git a/apps/partner-sas/app/page.module.css b/apps/partner-sas/app/[lang]/page.module.css similarity index 100% rename from apps/partner-sas/app/page.module.css rename to apps/partner-sas/app/[lang]/page.module.css diff --git a/apps/partner-sas/app/page.tsx b/apps/partner-sas/app/[lang]/page.tsx similarity index 97% rename from apps/partner-sas/app/page.tsx rename to apps/partner-sas/app/[lang]/page.tsx index 9b0be0a54..be56c55ef 100644 --- a/apps/partner-sas/app/page.tsx +++ b/apps/partner-sas/app/[lang]/page.tsx @@ -4,7 +4,8 @@ import { Typography } from "@scandic-hotels/design-system/Typography" import { serverClient } from "@/lib/trpc" -import { getIntl } from "../i18n" +import { getIntl } from "@/i18n" + import { ClientComponent } from "./ClientComponent" import styles from "./page.module.css" diff --git a/apps/partner-sas/app/globals.css b/apps/partner-sas/app/globals.css deleted file mode 100644 index 25fa51c5a..000000000 --- a/apps/partner-sas/app/globals.css +++ /dev/null @@ -1,2 +0,0 @@ -body { -} diff --git a/apps/partner-sas/env/server.ts b/apps/partner-sas/env/server.ts new file mode 100644 index 000000000..032203dff --- /dev/null +++ b/apps/partner-sas/env/server.ts @@ -0,0 +1,18 @@ +import { createEnv } from "@t3-oss/env-nextjs" +import { z } from "zod" + +export const env = createEnv({ + /** + * Due to t3-env only checking typeof window === "undefined" + * and Netlify running Deno, window is never "undefined" + * https://github.com/t3-oss/t3-env/issues/154 + */ + isServer: typeof window === "undefined" || "Deno" in window, + server: { + PUBLIC_URL: z.string().default(""), + }, + emptyStringAsUndefined: true, + runtimeEnv: { + PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL, + }, +}) diff --git a/apps/partner-sas/middleware.ts b/apps/partner-sas/middleware.ts new file mode 100644 index 000000000..785855436 --- /dev/null +++ b/apps/partner-sas/middleware.ts @@ -0,0 +1,113 @@ +import * as Sentry from "@sentry/nextjs" +import { type NextMiddleware, NextResponse } from "next/server" + +import { Lang } from "@scandic-hotels/common/constants/language" +import { logger } from "@scandic-hotels/common/logger" +import { findLang } from "@scandic-hotels/common/utils/languages" + +import * as invalidUrl from "@/middlewares/invalidUrl" +import * as trailingSlash from "@/middlewares/trailingSlash" +import { getDefaultRequestHeaders } from "@/middlewares/utils" + +export const middleware: NextMiddleware = async (request, event) => { + // auth() overrides the request origin, we need the original for internal rewrites + // @see getInternalNextURL() + request.headers.set("x-sh-origin", request.nextUrl.origin) + + const headers = getDefaultRequestHeaders(request) + const lang = findLang(request.nextUrl.pathname) + + if (!lang) { + // Lang is required for all our middleware. + // Without it we shortcircuit early. + + // Default to English if no lang is found. + headers.set("x-lang", Lang.en) + return NextResponse.rewrite( + new URL(`/${Lang.en}/middleware-error/404`, request.nextUrl), + { + request: { + headers, + }, + status: 404, + statusText: "Not found", + } + ) + } + + // Note that the order of middlewares is important since that is the order they are matched by. + const middlewares: { middleware: NextMiddleware; matcher: any }[] = [ + invalidUrl, + trailingSlash, + // authRequired, + // handleAuth, + // bookingFlow, + // cmsContent, + ] + + try { + for (let i = 0; i < middlewares.length; ++i) { + const middleware = middlewares[i] + + if (middleware.matcher(request)) { + const result = await middleware.middleware(request, event) + + const _continue = result?.headers.get("x-continue") + if (_continue) { + continue + } + // Clean up internal headers + result?.headers.delete("x-sh-origin") + + return result + } + } + } catch (e) { + if (e instanceof NextResponse && e.status) { + const cause = await e.json() + logger.error(`NextResponse Error in middleware`, cause) + Sentry.captureException(cause) + + return NextResponse.rewrite( + new URL(`/${lang}/middleware-error/${e.status}`, request.nextUrl), + { + request: { + headers, + }, + status: e.status, + statusText: e.statusText, + } + ) + } + + logger.error(`Error in middleware`, e) + Sentry.captureException(e) + + return NextResponse.rewrite( + new URL(`/${lang}/middleware-error/500`, request.nextUrl), + { + request: { + headers, + }, + status: 500, + statusText: "Internal Server Error", + } + ) + } + + // Follow through with normal App router rules. + return NextResponse.next({ + request: { + headers, + }, + }) +} + +export const config = { + /** + * Copied from Clerk to protect all routes by default and handle + * public routes inside middleware. + * (https://clerk.com/docs/quickstarts/nextjs?utm_source=sponsorship&utm_medium=youtube&utm_campaign=code-with-antonio&utm_content=12-31-2023#add-authentication-to-your-app) + */ + matcher: ["/((?!.+\\.[\\w]+$|_next|_static|.netlify|api|trpc|sitemap).*)"], +} diff --git a/apps/partner-sas/middlewares/invalidUrl.ts b/apps/partner-sas/middlewares/invalidUrl.ts new file mode 100644 index 000000000..f1351bade --- /dev/null +++ b/apps/partner-sas/middlewares/invalidUrl.ts @@ -0,0 +1,16 @@ +import { type NextMiddleware, NextResponse } from "next/server" + +import { getDefaultRequestHeaders } from "./utils" + +import type { MiddlewareMatcher } from "./types" + +export const middleware: NextMiddleware = async (request) => { + const headers = getDefaultRequestHeaders(request) + return NextResponse.next({ + headers, + }) +} + +export const matcher: MiddlewareMatcher = (request) => { + return request.nextUrl.pathname.includes("*") +} diff --git a/apps/partner-sas/middlewares/trailingSlash.ts b/apps/partner-sas/middlewares/trailingSlash.ts new file mode 100644 index 000000000..a4ca2fc22 --- /dev/null +++ b/apps/partner-sas/middlewares/trailingSlash.ts @@ -0,0 +1,25 @@ +import { type NextMiddleware, NextResponse } from "next/server" + +import { getPublicNextURL } from "@/server/utils" + +import { getDefaultRequestHeaders } from "./utils" + +import type { MiddlewareMatcher } from "./types" + +export const middleware: NextMiddleware = async (request) => { + const headers = getDefaultRequestHeaders(request) + + const newUrl = new URL( + request.nextUrl.pathname.slice(0, -1), + getPublicNextURL(request) + ) + + return NextResponse.redirect(newUrl, { + headers, + status: 308, + }) +} + +export const matcher: MiddlewareMatcher = (request) => { + return request.nextUrl.pathname.endsWith("/") +} diff --git a/apps/partner-sas/middlewares/types.ts b/apps/partner-sas/middlewares/types.ts new file mode 100644 index 000000000..ac9e53699 --- /dev/null +++ b/apps/partner-sas/middlewares/types.ts @@ -0,0 +1,3 @@ +import type { NextRequest } from "next/server" + +export type MiddlewareMatcher = (request: NextRequest) => boolean diff --git a/apps/partner-sas/middlewares/utils.ts b/apps/partner-sas/middlewares/utils.ts new file mode 100644 index 000000000..5f48598f7 --- /dev/null +++ b/apps/partner-sas/middlewares/utils.ts @@ -0,0 +1,22 @@ +import { findLang } from "@scandic-hotels/common/utils/languages" +import { removeTrailingSlash } from "@scandic-hotels/common/utils/url" + +import { getPublicNextURL } from "@/server/utils" + +import type { NextRequest } from "next/server" + +export function getDefaultRequestHeaders(request: NextRequest) { + const lang = findLang(request.nextUrl.pathname)! + const nextUrlPublic = getPublicNextURL(request) + const headers = new Headers(request.headers) + headers.set("x-lang", lang) + headers.set( + "x-pathname", + removeTrailingSlash( + request.nextUrl.pathname.replace(`/${lang}`, "").replace(`/webview`, "") + ) + ) + headers.set("x-url", removeTrailingSlash(nextUrlPublic.href)) + + return headers +} diff --git a/apps/partner-sas/server/utils.ts b/apps/partner-sas/server/utils.ts new file mode 100644 index 000000000..c1ceac4e3 --- /dev/null +++ b/apps/partner-sas/server/utils.ts @@ -0,0 +1,94 @@ +import { NextRequest } from "next/server" + +import { logger } from "@scandic-hotels/common/logger" + +import { env } from "@/env/server" +/** + * Use this function when you want to create URLs that are public facing, for + * example for redirects or redirectTo query parameters. + * Dedicated environments are behind Akamai (test, stage, production). They have + * env.PUBLIC_URL set. + * All other environment like deploy previews and branch deployments are not + * behind Akamai and therefore do not have env.PUBLIC_URL set. + * We need this approach because Netlify uses x-forwarded-host internally and + * strips it from ever reaching our code. + * TODO: Replace this approach with custom header in Akamai that mirrors the + * value in x-forwarded-host which would not get stripped by Netlify. + * @param request The incoming request. + * @returns NextURL The public facing URL instance for the given request. + */ +export function getPublicNextURL(request: NextRequest) { + if (env.PUBLIC_URL) { + const publicNextURL = request.nextUrl.clone() + // Akamai in front of Netlify for dedicated environments + // require us to rewrite the incoming host and hostname + // to match the public URL used to visit Akamai. + const url = new URL(env.PUBLIC_URL) + publicNextURL.host = url.host + publicNextURL.hostname = url.hostname + return publicNextURL + } + return request.nextUrl +} + +/** + * Use this function when you want the public facing URL for the given request. + * Read about the motivation in getPublicNextURL above. + * @see getPublicNextURL + * @param request The incoming request. + * @returns string The public facing origin for the given request. + */ +export function getPublicURL(request: NextRequest) { + if (env.PUBLIC_URL) { + return env.PUBLIC_URL + } + return request.nextUrl.origin +} + +/** + * Use this function when you want to create URLs that are internal (behind Akamai), + * for example for rewrites. Mainly used for middleware wrapped in auth(). + * The auth() function overrides the origin of the incoming request. It sets it + * to the origin of AUTH_URL/NEXTAUTH_URL. This means we cannot use the augmented + * request from auth() for rewrites, as those will point to auth url origin + * (in front of Akamai) and not the origin of the incoming request (behind Akamai). + * This results in rewrites going over the internet instead of going through the + * internal routing at Netlify. + * For dedicated environments (test, stage and production) we are behind Akamai. + * For those we have set a value for AUTH_URL/NEXTAUTH_URL, they point to the + * PUBLIC_URL. For rewrites we need the internal origin inside Netlify. + * In middleware.ts we copy the incoming origin to a header 'x-sh-origin'. We try + * and use that first, if not present we assume the internal origin is the value + * of the host header, as that is what Netlify used for routing to this deployment. + * @param request The incoming request. + * @returns NextURL The internal request, in Netlify behind Akamai. + */ +export function getInternalNextURL(request: NextRequest) { + const { href, origin } = request.nextUrl + + const originHeader = request.headers.get("x-sh-origin") + if (originHeader) { + logger.debug(`[internalNextUrl] using x-sh-origin header`, { + origin, + originHeader, + newOrigin: href.replace(origin, originHeader), + }) + return new NextRequest(href.replace(origin, originHeader), request).nextUrl + } + + const hostHeader = request.headers.get("host") + if (hostHeader) { + const inputHostOrigin = `${request.nextUrl.protocol}//${hostHeader}` + logger.debug(`[internalNextUrl] using host header`, { + origin, + hostHeader, + hostOrigin: inputHostOrigin, + newOrigin: href.replace(origin, inputHostOrigin), + }) + const { origin: hostOrigin } = new URL(inputHostOrigin) + return new NextRequest(href.replace(origin, hostOrigin), request).nextUrl + } + + logger.debug(`[internalNextUrl] falling back to incoming request`) + return request.nextUrl +}