From f47891003eab1ba981ee4924a33e2a09221c5b61 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 2 Oct 2025 16:27:20 -0600 Subject: [PATCH] add browser login path --- .../api/buster_rest/users/queryRequests.ts | 8 ++ .../screenshots/browser-login.ts | 82 +++++++++++++++++++ apps/web/src/lib/routes/index.ts | 42 ++++++++++ apps/web/src/routes/screenshots/_content.tsx | 16 +--- .../_content/metrics.$metricId.content.tsx | 21 ++--- .../screenshots/metrics.$metricId.index.tsx | 70 ++++------------ 6 files changed, 160 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/api/server-functions/screenshots/browser-login.ts diff --git a/apps/web/src/api/buster_rest/users/queryRequests.ts b/apps/web/src/api/buster_rest/users/queryRequests.ts index 696eebb44..2cee4009d 100644 --- a/apps/web/src/api/buster_rest/users/queryRequests.ts +++ b/apps/web/src/api/buster_rest/users/queryRequests.ts @@ -40,6 +40,14 @@ export const prefetchGetMyUserInfo = async (queryClient: QueryClient) => { return queryClient.getQueryData(userQueryKeys.userGetUserMyself.queryKey); }; +export const ensureGetMyUserInfo = async (queryClient: QueryClient) => { + await queryClient.ensureQueryData({ + ...userQueryKeys.userGetUserMyself, + queryFn: () => getMyUserInfo(), + }); + return queryClient.getQueryData(userQueryKeys.userGetUserMyself.queryKey); +}; + export const useGetUser = (params: Parameters[0]) => { const queryFn = () => getUser(params); return useQuery({ diff --git a/apps/web/src/api/server-functions/screenshots/browser-login.ts b/apps/web/src/api/server-functions/screenshots/browser-login.ts new file mode 100644 index 000000000..f7a4641a0 --- /dev/null +++ b/apps/web/src/api/server-functions/screenshots/browser-login.ts @@ -0,0 +1,82 @@ +import { chromium } from 'playwright'; +import { env } from '@/env'; +import { getSupabaseServerClient } from '@/integrations/supabase/server'; + +export const browserLogin = async ({ + accessToken, + width, + height, + fullPath, + request, +}: { + accessToken: string; + width: number; + height: number; + fullPath: string; + request: Request; +}) => { + const supabase = getSupabaseServerClient(); + const jwtPayload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString()); + const origin = new URL(request.url).origin; + + const { + data: { user }, + } = await supabase.auth.getUser(accessToken); + + if (!user || user?.is_anonymous) { + throw new Error('User not found'); + } + + const session = { + access_token: accessToken, + token_type: 'bearer', + expires_in: 3600, + expires_at: jwtPayload.exp, + refresh_token: '', + user: user, + }; + + const browser = await chromium.launch(); + + try { + const context = await browser.newContext({ + viewport: { width, height }, + }); + + const cookieKey = (supabase as unknown as { storageKey: string }).storageKey; + + // Format cookie value as Supabase expects: base64- + const cookieValue = `base64-${Buffer.from(JSON.stringify(session)).toString('base64')}`; + + await context.addCookies([ + { + name: cookieKey, + value: cookieValue, + domain: new URL(env.VITE_PUBLIC_URL).hostname, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + }, + ]); + + const page = await context.newPage(); + const fullPathWithOrigin = `${origin}${fullPath}`; + + page.on('console', (msg) => { + const hasError = msg.type() === 'error'; + if (hasError) { + browser.close(); + throw new Error(`Error in browser: ${msg.text()}`); + } + }); + + await page.goto(fullPathWithOrigin, { waitUntil: 'networkidle' }); + + return { context, browser, page }; + } catch (error) { + console.error('Error logging in to browser', error); + await browser.close(); + throw error; + } +}; diff --git a/apps/web/src/lib/routes/index.ts b/apps/web/src/lib/routes/index.ts index 1f4255db4..abbaca920 100644 --- a/apps/web/src/lib/routes/index.ts +++ b/apps/web/src/lib/routes/index.ts @@ -22,3 +22,45 @@ export function defineLinkFromFactory(fromOptions: { from: export const createFullURL = (location: ParsedLocation | string): string => window.location.origin + (typeof location === 'string' ? (location as string) : (location as ParsedLocation).href); + +export const createHrefFromLink = < + TRouter extends RegisteredRouter, + TOptions, + TFrom extends string = string, +>( + link: ILinkProps +) => { + const buildLink = defineLink(link); + + // Start with the 'to' path + let href = typeof buildLink.to === 'string' ? buildLink.to : ''; + + // Replace path params + if (buildLink.params && typeof buildLink.params === 'object') { + for (const [key, value] of Object.entries(buildLink.params)) { + href = href.replace(`:${key}`, String(value)); + href = href.replace(`$${key}`, String(value)); + } + } + + // Append search params + if (buildLink.search && typeof buildLink.search === 'object') { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(buildLink.search)) { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + } + const searchString = searchParams.toString(); + if (searchString) { + href += `?${searchString}`; + } + } + + // Append hash if present + if (buildLink.hash) { + href += buildLink.hash; + } + + return href; +}; diff --git a/apps/web/src/routes/screenshots/_content.tsx b/apps/web/src/routes/screenshots/_content.tsx index c8a09384c..85361e73c 100644 --- a/apps/web/src/routes/screenshots/_content.tsx +++ b/apps/web/src/routes/screenshots/_content.tsx @@ -1,7 +1,5 @@ import { createFileRoute, Outlet } from '@tanstack/react-router'; -import { getWebRequest } from '@tanstack/react-start/server'; -import { prefetchGetMyUserInfo } from '@/api/buster_rest/users'; -import { env } from '@/env'; +import { ensureGetMyUserInfo } from '@/api/buster_rest/users'; import { getSupabaseSession } from '@/integrations/supabase/getSupabaseUserClient'; export const Route = createFileRoute('/screenshots/_content')({ @@ -9,16 +7,8 @@ export const Route = createFileRoute('/screenshots/_content')({ component: RouteComponent, beforeLoad: async ({ context }) => { const user = await getSupabaseSession(); - await prefetchGetMyUserInfo(context.queryClient); - return { - user, - }; - }, - loader: async ({ context }) => { - const { user } = context; - return { - user, - }; + await ensureGetMyUserInfo(context.queryClient); + return { user }; }, }); diff --git a/apps/web/src/routes/screenshots/_content/metrics.$metricId.content.tsx b/apps/web/src/routes/screenshots/_content/metrics.$metricId.content.tsx index 151bd3aff..a76972d13 100644 --- a/apps/web/src/routes/screenshots/_content/metrics.$metricId.content.tsx +++ b/apps/web/src/routes/screenshots/_content/metrics.$metricId.content.tsx @@ -1,6 +1,5 @@ import { createFileRoute } from '@tanstack/react-router'; -import { getCookie } from '@tanstack/react-start/server'; -import { z } from 'zod'; +import { prefetchGetMetric } from '@/api/buster_rest/metrics'; import { useGetUserBasicInfo } from '@/api/buster_rest/users/useGetUserInfo'; import { Route as ScreenshotsRoute } from '../_content'; import { GetMetricScreenshotQuerySchema } from '../metrics.$metricId.index'; @@ -9,27 +8,29 @@ export const Route = createFileRoute('/screenshots/_content/metrics/$metricId/co component: RouteComponent, validateSearch: GetMetricScreenshotQuerySchema, ssr: true, - beforeLoad: async ({ context }) => { - const supabaseCookie = await getCookie('sb-127-auth-token'); - console.log('--------------------------------'); - console.log(supabaseCookie); - console.log('--------------------------------'); + beforeLoad: async ({ context, params, search, matches }) => { + const lastMatch = matches[matches.length - 1]; + const res = await prefetchGetMetric(context.queryClient, { + id: params.metricId, + version_number: search.version_number, + }); + if (!res || true) { + throw new Error('Metric not found'); + } return { - supabaseCookie, + metric: res, }; }, }); function RouteComponent() { const { version_number, type, width, height } = Route.useSearch(); - const { user } = ScreenshotsRoute.useLoaderData(); const x = useGetUserBasicInfo(); return (
Hello "/screenshot/hello-world"!
{x?.name}
-
{user.accessToken}
); } diff --git a/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx b/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx index 00416cd68..997267342 100644 --- a/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx +++ b/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx @@ -1,10 +1,8 @@ import { createServerFileRoute } from '@tanstack/react-start/server'; -import { chromium } from 'playwright'; import { z } from 'zod'; -import { getMetric } from '@/api/buster_rest/metrics'; -import { env } from '@/env'; +import { browserLogin } from '@/api/server-functions/screenshots/browser-login'; import { getSupabaseServerClient } from '@/integrations/supabase/server'; -import { Route as MetricContentRoute } from './_content/metrics.$metricId.content'; +import { createHrefFromLink } from '@/lib/routes'; const isDev = import.meta.env.DEV; @@ -37,63 +35,23 @@ export const ServerRoute = createServerFileRoute('/screenshots/metrics/$metricId const { version_number, type, width, height } = GetMetricScreenshotQuerySchema.parse( Object.fromEntries(new URL(request.url).searchParams) ); - const origin = new URL(request.url).origin; - // For Playwright, we need to reconstruct the session from the JWT - // Decode the JWT to get expiry time - const jwtPayload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString()); + const { browser, page } = await browserLogin({ + accessToken, + width, + height, + fullPath: createHrefFromLink({ + to: '/screenshots/metrics/$metricId/content', + params: { metricId }, + search: { version_number, type, width, height }, + }), + request, + }); - const session = { - access_token: accessToken, - token_type: 'bearer', - expires_in: 3600, - expires_at: jwtPayload.exp, - refresh_token: '', - user: user, - }; - - console.time('capture screenshot'); - - const browser = await chromium.launch(); - console.timeLog('capture screenshot', 'browser launched'); try { - // Create browser context with authentication cookies - const context = await browser.newContext({ - viewport: { width, height }, - }); - - // Extract project ref from Supabase URL (e.g., "abcdefg" from "abcdefg.supabase.co") - const projectRef = '127'; - - // Format cookie value as Supabase expects: base64- - const cookieValue = `base64-${Buffer.from(JSON.stringify(session)).toString('base64')}`; - - console.log('Project ref:', projectRef); - console.log('Cookie name:', `sb-${projectRef}-auth-token`); - - await context.addCookies([ - { - name: `sb-${projectRef}-auth-token`, - value: cookieValue, - domain: new URL(env.VITE_PUBLIC_URL).hostname, - path: '/', - httpOnly: true, - secure: true, - sameSite: 'Lax', - }, - ]); - - const page = await context.newPage(); - console.timeLog('capture screenshot', 'page created with auth cookie'); - - const fullPath = `${origin}${MetricContentRoute.fullPath}`; - await page.goto(fullPath, { waitUntil: 'networkidle' }); - console.timeLog('capture screenshot', 'page navigated'); const screenshotBuffer = await page.screenshot({ type, }); - console.timeLog('capture screenshot', 'screenshot taken'); - console.timeEnd('capture screenshot'); if (!isDev) { return new Response( @@ -116,7 +74,7 @@ export const ServerRoute = createServerFileRoute('/screenshots/metrics/$metricId }, }); } catch (error) { - // console.error('Error capturing metric screenshot', error); + console.error('Error capturing metric screenshot', error); return new Response( JSON.stringify({ message: 'Failed to capture screenshot',