fix: redirect users to /refresh on unauth and mod webview links

This commit is contained in:
Christel Westerberg
2024-05-16 16:57:22 +02:00
parent 777fd1e5b6
commit 9e4f41ee46
29 changed files with 358 additions and 105 deletions

View File

@@ -1,14 +1,14 @@
import { notFound, redirect } from "next/navigation" import { notFound } from "next/navigation"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import { Blocks } from "@/components/Loyalty/Blocks" import { Blocks } from "@/components/Loyalty/Blocks/WebView"
import Sidebar from "@/components/Loyalty/Sidebar" import Sidebar from "@/components/Loyalty/Sidebar"
import MaxWidth from "@/components/MaxWidth" import MaxWidth from "@/components/MaxWidth"
import styles from "./page.module.css" import styles from "./page.module.css"
import type { LangParams, PageArgs, UriParams } from "@/types/params" import { LangParams, PageArgs, UriParams } from "@/types/params"
export default async function AboutScandicFriends({ export default async function AboutScandicFriends({
params, params,
@@ -18,26 +18,16 @@ export default async function AboutScandicFriends({
return notFound() return notFound()
} }
const loyaltyPage = await serverClient({ const loyaltyPage = await serverClient().contentstack.loyaltyPage.get({
onError() {
const returnUrl = new URLSearchParams({
returnurl: `${params.lang}/webview/${searchParams.uri}`,
})
const refreshUrl = `/${params.lang}/webview/refresh?${returnUrl.toString()}`
redirect(refreshUrl)
},
}).contentstack.loyaltyPage.get({
href: searchParams.uri, href: searchParams.uri,
locale: params.lang, locale: params.lang,
}) })
return ( return (
<section className={styles.content}> <section className={styles.content}>
{loyaltyPage.sidebar ? <Sidebar blocks={loyaltyPage.sidebar} /> : null} {loyaltyPage.sidebar ? <Sidebar blocks={loyaltyPage.sidebar} /> : null}
<MaxWidth className={styles.blocks} tag="main"> <MaxWidth className={styles.blocks} tag="main">
<Blocks blocks={loyaltyPage.blocks} /> <Blocks blocks={loyaltyPage.blocks} lang={params.lang} />
</MaxWidth> </MaxWidth>
</section> </section>
) )

View File

@@ -1,10 +1,8 @@
import "@/app/globals.css" import "@/app/globals.css"
import "@scandic-hotels/design-system/style.css" import "@scandic-hotels/design-system/style.css"
import { notFound, redirect } from "next/navigation" import { notFound } from "next/navigation"
import { Lang } from "@/constants/languages"
import { overview } from "@/constants/routes/webviews"
import { _ } from "@/lib/translation" import { _ } from "@/lib/translation"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
@@ -13,21 +11,7 @@ import Content from "@/components/MyPages/AccountPage/Webview/Content"
import styles from "./page.module.css" import styles from "./page.module.css"
import type { LangParams, PageArgs, UriParams } from "@/types/params" import { LangParams, PageArgs, UriParams } from "@/types/params"
function getLink(lang: Lang, uri: string) {
if (uri === overview[lang]) {
return {
title: _("Go to points"),
href: `/${lang}/webview/my-pages/points`,
}
} else {
return {
title: _("Go to membership overview"),
href: `/${lang}/webview/my-pages/overview`,
}
}
}
export default async function MyPages({ export default async function MyPages({
params, params,
@@ -37,34 +21,11 @@ export default async function MyPages({
return notFound() return notFound()
} }
// Check if the access token is valid. If not, redirect to the refresh page. const accountPage = await serverClient().contentstack.accountPage.get({
await serverClient({
onError(opts) {
const returnUrl = new URLSearchParams({
returnurl: `${params.lang}/webview/${searchParams.uri}`,
})
const refreshUrl = `/${params.lang}/webview/refresh?${returnUrl.toString()}`
redirect(refreshUrl)
},
}).user.get()
const accountPage = await serverClient({
onError() {
const returnUrl = new URLSearchParams({
returnurl: `${params.lang}/webview/${searchParams.uri}`,
})
const refreshUrl = `/${params.lang}/webview/refresh?${returnUrl.toString()}`
redirect(refreshUrl)
},
}).contentstack.accountPage.get({
url: searchParams.uri, url: searchParams.uri,
lang: params.lang, lang: params.lang,
}) })
const link = getLink(params.lang, searchParams.uri)
return ( return (
<MaxWidth className={styles.blocks} tag="main"> <MaxWidth className={styles.blocks} tag="main">
<Content lang={params.lang} content={accountPage.content} /> <Content lang={params.lang} content={accountPage.content} />

View File

@@ -0,0 +1,3 @@
export default function Refresh() {
return <div>Hey you&apos;ve been refreshed</div>
}

View File

@@ -0,0 +1,66 @@
import JsonToHtml from "@/components/JsonToHtml"
import DynamicContentBlock from "@/components/Loyalty/Blocks/DynamicContent"
import Shortcuts from "@/components/MyPages/Blocks/Shortcuts"
import { modWebviewLink } from "@/utils/webviews"
import CardGrid from "../CardGrid"
import type { BlocksProps } from "@/types/components/loyalty/blocks"
import { LoyaltyBlocksTypenameEnum } from "@/types/components/loyalty/enums"
import { LangParams } from "@/types/params"
export function Blocks({ lang, blocks }: BlocksProps & LangParams) {
return blocks.map((block) => {
switch (block.__typename) {
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksCardGrid:
const cardGrid = {
...block.card_grid,
cards: block.card_grid.cards.map((card) => {
return {
...card,
link: card.link
? { ...card.link, href: modWebviewLink(card.link.href, lang) }
: undefined,
}
}),
}
return <CardGrid card_grid={cardGrid} />
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksContent:
return (
<section>
<JsonToHtml
nodes={block.content.content.json.children}
embeds={block.content.content.embedded_itemsConnection.edges}
/>
</section>
)
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksDynamicContent:
const dynamicContent = {
...block.dynamic_content,
linK: block.dynamic_content.link
? {
...block.dynamic_content.link,
href: modWebviewLink(block.dynamic_content.link.href, lang),
}
: undefined,
}
return <DynamicContentBlock dynamicContent={dynamicContent} />
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksShortcuts:
const shortcuts = block.shortcuts.shortcuts.map((shortcut) => ({
...shortcut,
url: modWebviewLink(shortcut.url, lang),
}))
return (
<Shortcuts
shortcuts={shortcuts}
title={block.shortcuts.title}
subtitle={block.shortcuts.preamble}
/>
)
default:
return null
}
})
}

View File

@@ -1,6 +1,7 @@
import JsonToHtml from "@/components/JsonToHtml" import JsonToHtml from "@/components/JsonToHtml"
import Overview from "@/components/MyPages/Blocks/Overview" import Overview from "@/components/MyPages/Blocks/Overview"
import Shortcuts from "@/components/MyPages/Blocks/Shortcuts" import Shortcuts from "@/components/MyPages/Blocks/Shortcuts"
import { modWebviewLink } from "@/utils/webviews"
import { import {
AccountPageContentProps, AccountPageContentProps,
@@ -31,7 +32,7 @@ export default function Content({ lang, content }: ContentProps) {
href: href:
item.dynamic_content.link.linkConnection.edges[0].node item.dynamic_content.link.linkConnection.edges[0].node
.original_url || .original_url ||
`/${lang}${item.dynamic_content.link.linkConnection.edges[0].node.url}`, `/${lang}/webview${item.dynamic_content.link.linkConnection.edges[0].node.url}`,
text: item.dynamic_content.link.link_text, text: item.dynamic_content.link.link_text,
} }
: null : null
@@ -50,9 +51,15 @@ export default function Content({ lang, content }: ContentProps) {
/> />
) )
case ContentEntries.AccountPageContentShortcuts: case ContentEntries.AccountPageContentShortcuts:
const shortcuts = item.shortcuts.shortcuts.map((shortcut) => {
return {
...shortcut,
url: modWebviewLink(shortcut.url, lang),
}
})
return ( return (
<Shortcuts <Shortcuts
shortcuts={item.shortcuts.shortcuts} shortcuts={shortcuts}
subtitle={item.shortcuts.preamble} subtitle={item.shortcuts.preamble}
title={item.shortcuts.title} title={item.shortcuts.title}
/> />

View File

@@ -1,3 +1,4 @@
import { Lang } from "@/constants/languages"
import { _ } from "@/lib/translation" import { _ } from "@/lib/translation"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
@@ -6,8 +7,17 @@ import BreadcrumbsWithLink from "./BreadcrumbWithLink"
import styles from "./breadcrumbs.module.css" import styles from "./breadcrumbs.module.css"
export default async function Breadcrumbs() { export default async function Breadcrumbs({
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get() href,
locale,
}: {
href: string
locale: Lang
}) {
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get({
href,
locale,
})
return ( return (
<nav className={styles.breadcrumbs}> <nav className={styles.breadcrumbs}>

View File

@@ -0,0 +1,16 @@
.hamburger {
background: none;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0;
}
.line {
background-color: var(--some-black-color, #1c1b1f);
border-radius: 0.8rem;
height: 0.2rem;
width: 2.5rem;
}

View File

@@ -0,0 +1,11 @@
import styles from "./hamburger.module.css"
export default function Hamburger() {
return (
<button className={styles.hamburger} type="button">
<div className={styles.line} />
<div className={styles.line} />
<div className={styles.line} />
</button>
)
}

View File

@@ -0,0 +1,17 @@
import Image from "@/components/Image"
import styles from "./language.module.css"
export default function LanguageSwitcher() {
return (
<div className={styles.switcher}>
<Image
alt="Swedish flag"
height={21}
src="/_static/icons/sweden.svg"
width={21}
/>
<span>SV / SEK</span>
</div>
)
}

View File

@@ -0,0 +1,15 @@
.switcher {
align-items: center;
display: none;
font-family: var(--ff-fira-sans);
font-size: 1.4rem;
font-weight: 400;
gap: 0.6rem;
line-height: 1.6rem;
}
@media screen and (min-width: 950px) {
.switcher {
display: flex;
}
}

View File

@@ -0,0 +1,34 @@
import Link from "next/link"
import { GetMyPagesLogo } from "@/lib/graphql/Query/Logo.graphql"
import { request } from "@/lib/graphql/request"
import Image from "@/components/Image"
import styles from "./logo.module.css"
import type { LangParams } from "@/types/params"
import type { LogoQueryData } from "@/types/requests/myPages/logo"
export default async function Logo({ lang }: LangParams) {
const { data } = await request<LogoQueryData>(GetMyPagesLogo, {
locale: lang,
})
if (
!data.all_header.items.length ||
!data.all_header.items?.[0].logoConnection.totalCount
) {
return null
}
const logo = data.all_header.items[0].logoConnection.edges[0]
return (
<Link className={styles.link} href="#">
<Image
alt={logo.node.title}
height={logo.node.dimension.height}
src={logo.node.url}
width={logo.node.dimension.width}
/>
</Link>
)
}

View File

@@ -0,0 +1,4 @@
.link {
cursor: pointer;
display: block;
}

View File

@@ -0,0 +1,25 @@
.header {
align-items: center;
background-color: var(--some-white-color, #fff);
box-shadow: 0px 1.0006656646728516px 1.0006656646728516px 0px #0000000d;
display: grid;
gap: 3rem;
grid-template-columns: 1fr auto auto;
height: var(--header-height);
padding: 0 2rem;
position: sticky;
top: 0;
z-index: 999;
}
@media screen and (min-width: 950px) {
.header {
background-color: var(--some-grey-color, #ececec);
border-bottom: 0.1rem solid var(--some-grey-color, #ccc);
box-shadow: none;
gap: 3.2rem;
grid-template-columns: 1fr 19rem auto auto;
padding: 0 2.4rem;
}
}

View File

@@ -0,0 +1,19 @@
import Hamburger from "./Hamburger"
import LanguageSwitcher from "./LanguageSwitcher"
import Logo from "./Logo"
import User from "./User"
import styles from "./header.module.css"
import type { LangParams } from "@/types/params"
export default function Header({ lang }: LangParams) {
return (
<header className={styles.header}>
<Logo lang={lang} />
<LanguageSwitcher />
<User />
<Hamburger />
</header>
)
}

View File

@@ -57,11 +57,22 @@ export const programOverview = {
sv: `/sv/webview/om-scandic-friends`, sv: `/sv/webview/om-scandic-friends`,
} }
/** @type {import('@/types/routes').LangRoute} */
const refreshUrl = {
da: `/da/webview/refresh`,
de: `/de/webview/refresh`,
en: `/en/webview/refresh`,
fi: `/fi/webview/refresh`,
no: `/no/webview/refresh`,
sv: `/sv/webview/refresh`,
}
export const webviews = [ export const webviews = [
...Object.values(benefits), ...Object.values(benefits),
...Object.values(overview), ...Object.values(overview),
...Object.values(points), ...Object.values(points),
...Object.values(programOverview), ...Object.values(programOverview),
...Object.values(refreshUrl),
] ]
export const myPagesWebviews = [ export const myPagesWebviews = [
@@ -71,3 +82,5 @@ export const myPagesWebviews = [
] ]
export const loyaltyPagesWebviews = [...Object.values(programOverview)] export const loyaltyPagesWebviews = [...Object.values(programOverview)]
export const refreshWebviews = [...Object.values(refreshUrl)]

View File

@@ -0,0 +1,16 @@
#import "../Fragments/Image.graphql"
query GetMyPagesLogo($locale: String!) {
all_header(limit: 1, locale: $locale) {
items {
logoConnection {
edges {
node {
...Image
}
}
totalCount
}
}
}
}

View File

@@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server"
import { headers } from "next/headers"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
@@ -22,6 +23,16 @@ export function serverClient() {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
if (error.code === "UNAUTHORIZED") { if (error.code === "UNAUTHORIZED") {
const lang = ctx?.lang || Lang.en const lang = ctx?.lang || Lang.en
if (ctx?.webToken) {
const returnUrl = ctx.url
const redirectUrl = `/${lang}/webview/refresh?returnurl=${encodeURIComponent(returnUrl)}`
console.error(
"Unautorized in webview, redirecting to: ",
redirectUrl
)
redirect(redirectUrl)
}
const pathname = ctx?.pathname || "/" const pathname = ctx?.pathname || "/"
redirect( redirect(
`/${lang}/login?redirectTo=${encodeURIComponent(`/${lang}/${pathname}`)}` `/${lang}/login?redirectTo=${encodeURIComponent(`/${lang}/${pathname}`)}`

View File

@@ -42,7 +42,7 @@ export const middleware: NextMiddleware = async (request) => {
return NextResponse.rewrite( return NextResponse.rewrite(
new URL( new URL(
`/${lang}/preview/${contentType}/${uid}?${searchParams.toString()}`, `/${lang}/preview/${contentType}?${searchParams.toString()}`,
nextUrl nextUrl
), ),
{ {
@@ -53,6 +53,7 @@ export const middleware: NextMiddleware = async (request) => {
) )
} }
searchParams.set("uri", pathNameWithoutLang)
if (isCurrent) { if (isCurrent) {
searchParams.set("uri", pathNameWithoutLang) searchParams.set("uri", pathNameWithoutLang)
return NextResponse.rewrite( return NextResponse.rewrite(

View File

@@ -5,6 +5,7 @@ import { findLang } from "@/constants/languages"
import { import {
loyaltyPagesWebviews, loyaltyPagesWebviews,
myPagesWebviews, myPagesWebviews,
refreshWebviews,
webviews, webviews,
} from "@/constants/routes/webviews" } from "@/constants/routes/webviews"
import { env } from "@/env/server" import { env } from "@/env/server"
@@ -19,9 +20,21 @@ export const middleware: NextMiddleware = async (request) => {
const lang = findLang(nextUrl.pathname) const lang = findLang(nextUrl.pathname)
const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}/webview`, "") const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}/webview`, "")
const headers = new Headers()
// If user is redirected to /lang/webview/refresh/, the webview token is invalid and we remove the cookie
if (refreshWebviews.includes(nextUrl.pathname)) {
headers.set(
"Set-Cookie",
`webviewToken=0; Max-Age=0; Secure; HttpOnly; Path=/; SameSite=Strict;`
)
return NextResponse.rewrite(new URL(`/${lang}/webview/refresh`, nextUrl), {
headers,
})
}
const searchParams = new URLSearchParams(request.nextUrl.searchParams) const searchParams = new URLSearchParams(request.nextUrl.searchParams)
searchParams.set("uri", pathNameWithoutLang) searchParams.set("uri", pathNameWithoutLang)
const webviewToken = request.cookies.get("webviewToken") const webviewToken = request.cookies.get("webviewToken")
if (webviewToken) { if (webviewToken) {
// since the token exists, this is a subsequent visit // since the token exists, this is a subsequent visit
@@ -42,27 +55,33 @@ export const middleware: NextMiddleware = async (request) => {
} }
} }
// Authorization header is required for webviews
// It should be base64 encoded
const authorization = request.headers.get("Authorization")!
if (!authorization) {
return badRequest()
}
// Initialization vector header is required for webviews
// It should be base64 encoded
const initializationVector = request.headers.get("X-AES-IV")!
if (!initializationVector) {
return badRequest()
}
try { try {
// Authorization header is required for webviews
// It should be base64 encoded
const authorization = request.headers.get("Authorization")!
if (!authorization) {
return badRequest()
}
// Initialization vector header is required for webviews
// It should be base64 encoded
const initializationVector = request.headers.get("X-AES-IV")!
if (!initializationVector) {
return badRequest()
}
const decryptedData = await decryptData( const decryptedData = await decryptData(
env.WEBVIEW_ENCRYPTION_KEY, env.WEBVIEW_ENCRYPTION_KEY,
initializationVector, initializationVector,
authorization authorization
) )
headers.set(
"Set-Cookie",
`webviewToken=${decryptedData}; Secure; HttpOnly; Path=/; SameSite=Strict;`
)
headers.set("Cookie", `webviewToken=${decryptedData}`)
if (myPagesWebviews.includes(nextUrl.pathname)) { if (myPagesWebviews.includes(nextUrl.pathname)) {
return NextResponse.rewrite( return NextResponse.rewrite(
new URL( new URL(
@@ -70,10 +89,7 @@ export const middleware: NextMiddleware = async (request) => {
nextUrl nextUrl
), ),
{ {
headers: { headers,
"Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly; Path=/; SameSite=Strict;`,
Cookie: `webviewToken=${decryptedData}`,
},
} }
) )
} else if (loyaltyPagesWebviews.includes(nextUrl.pathname)) { } else if (loyaltyPagesWebviews.includes(nextUrl.pathname)) {
@@ -83,10 +99,7 @@ export const middleware: NextMiddleware = async (request) => {
nextUrl nextUrl
), ),
{ {
headers: { headers,
"Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly; Path=/; SameSite=Strict;`,
Cookie: `webviewToken=${decryptedData}`,
},
} }
) )
} }

View File

@@ -1,4 +1,4 @@
import { headers } from "next/headers" import { cookies, headers } from "next/headers"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
@@ -10,6 +10,7 @@ type CreateContextOptions = {
pathname: string pathname: string
uid?: string | null uid?: string | null
url: string url: string
webToken: string | undefined
} }
/** Use this helper for: /** Use this helper for:
@@ -23,6 +24,7 @@ export function createContextInner(opts: CreateContextOptions) {
pathname: opts.pathname, pathname: opts.pathname,
uid: opts.uid, uid: opts.uid,
url: opts.url, url: opts.url,
webToken: opts.webToken,
} }
} }
@@ -33,20 +35,8 @@ export function createContextInner(opts: CreateContextOptions) {
export function createContext() { export function createContext() {
const h = headers() const h = headers()
// const cookie = cookies() const cookie = cookies()
// const webviewTokenCookie = cookie.get("webviewToken") const webviewTokenCookie = cookie.get("webviewToken")
// if (webviewTokenCookie) {
// // since the token exists, this is a subsequent visit
// // we're done, allow it
// return createContextInner({
// session: {
// token: { access_token: webviewTokenCookie.value },
// },
// })
// }
// const session = await auth()
return createContextInner({ return createContextInner({
auth, auth,
@@ -54,6 +44,7 @@ export function createContext() {
pathname: h.get("x-pathname")!, pathname: h.get("x-pathname")!,
uid: h.get("x-uid"), uid: h.get("x-uid"),
url: h.get("x-url")!, url: h.get("x-url")!,
webToken: webviewTokenCookie?.value,
}) })
} }

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
import { Lang } from "@/constants/languages"
export const getBreadcrumbsInput = z.object({
href: z.string().min(1, { message: "href is required" }),
locale: z.nativeEnum(Lang),
})

View File

@@ -4,7 +4,6 @@ import {
} from "@/lib/graphql/Query/BreadcrumbsMyPages.graphql" } from "@/lib/graphql/Query/BreadcrumbsMyPages.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { import {
badRequestError,
internalServerError, internalServerError,
notFound, notFound,
} from "@/server/errors/trpc" } from "@/server/errors/trpc"
@@ -17,6 +16,7 @@ import {
} from "@/utils/generateTag" } from "@/utils/generateTag"
import { removeMultipleSlashes } from "@/utils/url" import { removeMultipleSlashes } from "@/utils/url"
import { getBreadcrumbsInput } from "./input"
import { import {
getBreadcrumbsSchema, getBreadcrumbsSchema,
validateBreadcrumbsConstenstackSchema, validateBreadcrumbsConstenstackSchema,

View File

@@ -29,8 +29,7 @@ export const contentstackProcedure = t.procedure.use(async function (opts) {
}) })
export const protectedProcedure = t.procedure.use(async function (opts) { export const protectedProcedure = t.procedure.use(async function (opts) {
const authRequired = opts.meta?.authRequired ?? true const authRequired = opts.meta?.authRequired ?? true
const ctx = await opts.ctx const session = await opts.ctx.auth()
const session = ctx.session
if (!authRequired && env.NODE_ENV === "development") { if (!authRequired && env.NODE_ENV === "development") {
console.info( console.info(
`❌❌❌❌ You are opting out of authorization, if its done on purpose maybe you should use the publicProcedure instead. ❌❌❌❌` `❌❌❌❌ You are opting out of authorization, if its done on purpose maybe you should use the publicProcedure instead. ❌❌❌❌`
@@ -48,7 +47,7 @@ export const protectedProcedure = t.procedure.use(async function (opts) {
return opts.next({ return opts.next({
ctx: { ctx: {
session, session: session || { token: { access_token: opts.ctx.webToken } },
}, },
}) })
}) })

View File

@@ -25,7 +25,7 @@ export type UIDParams = {
} }
export type UriParams = { export type UriParams = {
uri: string | string[] uri: string
} }
export type PreviewParams = { export type PreviewParams = {

View File

@@ -0,0 +1,10 @@
import type { Image } from "../../image"
import type { EdgesWithTotalCount } from "../utils/edges"
export type LogoQueryData = {
all_header: {
items: {
logoConnection: EdgesWithTotalCount<Image>
}[]
}
}

13
utils/webviews.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Lang } from "@/constants/languages"
import { webviews } from "@/constants/routes/webviews"
export function modWebviewLink(url: string, lang: Lang) {
const urlWithoutLang = url.replace(`/${lang}`, "")
const webviewUrl = `/${lang}/webview${urlWithoutLang}`
if (webviews.includes(webviewUrl) || url.startsWith("/webview")) {
return webviewUrl
} else {
return url
}
}