diff --git a/packages/frontend-2/lib/common/composables/reactiveCookie.ts b/packages/frontend-2/lib/common/composables/reactiveCookie.ts index 85a108c84..8838eaac9 100644 --- a/packages/frontend-2/lib/common/composables/reactiveCookie.ts +++ b/packages/frontend-2/lib/common/composables/reactiveCookie.ts @@ -1,16 +1,34 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import type { CookieOptions } from 'nuxt/dist/app/composables/cookie' import dayjs from 'dayjs' import { useScopedState } from '~~/lib/common/composables/scopedState' -import { isUndefined } from 'lodash-es' +import { get, isUndefined } from 'lodash-es' +import { isBraveOrSafari, type Nullable } from '@speckle/shared' + +class AbortControllerManager { + private abortController: Nullable = null + + getAndAbortOld() { + if (process.server) return null + + // Abort old + if (this.abortController) this.abortController.abort() + this.abortController = null + + // Create new + this.abortController = new AbortController() + return this.abortController + } +} + +const abortControllerManager = new AbortControllerManager() /** * Makes useCookie() synchronized across the app so that a change to it from one place * will also update other references elsewhere. * * Defaults to an expiration date of 1 year - * - * IMPORTANT NOTE: Both Safari & Brave limit client-side cookie max-age to 7 days. If your cookie is important, evaluate how to - * ensure that the cookie is written to from the server-side (either SSR render or API route) */ export const useSynchronizedCookie = ( name: string, @@ -25,9 +43,34 @@ export const useSynchronizedCookie = ( } // something's off with nuxt's types here, have to use any - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any const cookie = useCookie(name, finalOpts as any) + // Hack to resolve Safari & Brave limiting client-side cookies to 7 days - set temporary cookie to be read from server-side where it'll be fixed + if (process.client && isBraveOrSafari()) { + const tmpCookie = useCookie(`tmp-${name}`, finalOpts as any) + + watch(cookie, (newVal) => { + if (newVal) { + tmpCookie.value = JSON.stringify({ + expires: finalOpts.expires?.toISOString(), + maxAge: finalOpts.maxAge + }) + } else { + tmpCookie.value = undefined + } + + // Fetch w/ abort of previous call, if any + const controller = abortControllerManager.getAndAbortOld() + void fetch('/web-api/cookie-fix', { + signal: controller?.signal + }).catch((e) => { + if (get(e, 'name') !== 'AbortError') { + throw e + } + }) + }) + } + // there's a bug in nuxt where a default value doesn't get set if useCookie is only invoked in CSR // TODO: https://github.com/nuxt/nuxt/issues/26701 if (isUndefined(cookie.value) && opts?.default) { diff --git a/packages/frontend-2/plugins/002-rum.ts b/packages/frontend-2/plugins/002-rum.ts index 95e533e6b..eed82d207 100644 --- a/packages/frontend-2/plugins/002-rum.ts +++ b/packages/frontend-2/plugins/002-rum.ts @@ -7,6 +7,7 @@ import { useCreateErrorLoggingTransport } from '~/lib/core/composables/error' import type { Plugin } from 'nuxt/dist/app/nuxt' import { isH3Error } from '~/lib/common/helpers/error' import { useRequestId, useServerRequestId } from '~/lib/core/composables/server' +import { isBrave, isSafari } from '@speckle/shared' type PluginNuxtApp = Parameters[0] @@ -84,6 +85,14 @@ async function initRumClient(app: PluginNuxtApp) { datadog.onReady(async () => { if ('setGlobalContextProperty' in datadog && reqId?.length) { datadog.setGlobalContextProperty('requestId', reqId) + + if (isSafari()) { + datadog.setGlobalContextProperty('isSafari', 'true') + } + + if (isBrave()) { + datadog.setGlobalContextProperty('isBrave', 'true') + } } await onAuthStateChange( diff --git a/packages/frontend-2/server/routes/web-api/cookie-fix.ts b/packages/frontend-2/server/routes/web-api/cookie-fix.ts new file mode 100644 index 000000000..b55ad2328 --- /dev/null +++ b/packages/frontend-2/server/routes/web-api/cookie-fix.ts @@ -0,0 +1,56 @@ +import type { Optional } from '@speckle/shared' +import { has, isObjectLike } from 'lodash-es' + +type TempCookieValue = { expires?: Optional; maxAge?: Optional } + +const isValidTempCookieValue = (cookie: unknown): cookie is TempCookieValue => { + if (!isObjectLike(cookie)) return false + return has(cookie, 'expires') || has(cookie, 'maxAge') +} + +export default defineEventHandler((event) => { + const cookies = parseCookies(event) + const { + public: { baseUrl } + } = useRuntimeConfig() + const domain = new URL(baseUrl).hostname + + for (const [key, val] of Object.entries(cookies)) { + if (key.startsWith('tmp-')) { + // Try reading in cookie settings + let tempCookieVal: Optional = undefined + try { + const parsedVal = JSON.parse(val) as unknown + if (isValidTempCookieValue(parsedVal)) { + tempCookieVal = parsedVal + } + } catch (e) { + deleteCookie(event, key) + continue + } + + if (!tempCookieVal) { + deleteCookie(event, key) + continue + } + + // Try finding cookie that we need to fix + const cookieName = key.replace('tmp-', '') + const cookieValue = cookies[cookieName] + if (!cookieValue) { + deleteCookie(event, key) + continue + } + + // Create new cookie with the correct settings + setCookie(event, cookieName, cookieValue, { + maxAge: tempCookieVal.maxAge, + expires: tempCookieVal.expires ? new Date(tempCookieVal.expires) : undefined, + domain + }) + deleteCookie(event, key) + } + } + + return { status: 'ok' } +}) diff --git a/packages/shared/src/core/helpers/os.ts b/packages/shared/src/core/helpers/os.ts index c4da19fe7..9cfb64933 100644 --- a/packages/shared/src/core/helpers/os.ts +++ b/packages/shared/src/core/helpers/os.ts @@ -1,3 +1,4 @@ +import { get } from 'lodash' import type { Nullable } from './utilityTypes' export enum OperatingSystem { @@ -85,3 +86,19 @@ export function isSafari() { const userAgent = globalThis.navigator.userAgent return /^((?!chrome|android).)*safari/i.test(userAgent) } + +/** + * Check if user is in Brave browser + */ +export function isBrave() { + if (!globalThis || !globalThis.navigator || !('brave' in globalThis.navigator)) { + return false + } + + const braveObj = get(globalThis.navigator, 'brave') + if (!braveObj) return false + + return !!get(braveObj, 'isBrave', false) +} + +export const isBraveOrSafari = () => isBrave() || isSafari()