diff --git a/apps/server/env.d.ts b/apps/server/env.d.ts index a63085493..8d2c96386 100644 --- a/apps/server/env.d.ts +++ b/apps/server/env.d.ts @@ -15,6 +15,7 @@ declare global { SLACK_APP_SUPPORT_URL: string; SERVER_URL: string; NODE_ENV?: 'development' | 'production' | 'test'; + VITE_PUBLIC_URL: string; } } } diff --git a/apps/server/package.json b/apps/server/package.json index f33163dc0..cea6b70bb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -47,6 +47,7 @@ "lodash-es": "catalog:", "pino": "^9.10.0", "pino-pretty": "^13.1.1", + "playwright": "^1.55.1", "tsup": "catalog:", "yaml": "^2.8.1", "zod": "catalog:" diff --git a/apps/server/scripts/validate-env.ts b/apps/server/scripts/validate-env.ts index 27b5ae2bb..c3d583cf6 100644 --- a/apps/server/scripts/validate-env.ts +++ b/apps/server/scripts/validate-env.ts @@ -19,6 +19,8 @@ const requiredEnv = { SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET, SLACK_APP_SUPPORT_URL: process.env.SLACK_APP_SUPPORT_URL, SERVER_URL: process.env.SERVER_URL, + //WEB_URL + VITE_PUBLIC_URL: process.env.VITE_PUBLIC_URL, }; // Validate environment variables diff --git a/apps/server/src/api/v2/chats/[id]/screenshot/PUT.ts b/apps/server/src/api/v2/chats/[id]/screenshot/PUT.ts index 7769d2433..fcfb14e31 100644 --- a/apps/server/src/api/v2/chats/[id]/screenshot/PUT.ts +++ b/apps/server/src/api/v2/chats/[id]/screenshot/PUT.ts @@ -2,7 +2,7 @@ import { checkPermission } from '@buster/access-controls'; import { getChatById } from '@buster/database/queries'; import { AssetIdParamsSchema, - PutScreenshotRequestSchema, + PutMetricScreenshotRequestSchema, type PutScreenshotResponse, } from '@buster/server-shared/screenshots'; import { zValidator } from '@hono/zod-validator'; @@ -12,7 +12,7 @@ import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-sc const app = new Hono().put( '/', - zValidator('json', PutScreenshotRequestSchema), + zValidator('json', PutMetricScreenshotRequestSchema), zValidator('param', AssetIdParamsSchema), async (c) => { const assetId = c.req.valid('param').id; diff --git a/apps/server/src/api/v2/dashboards/[id]/screenshot/PUT.ts b/apps/server/src/api/v2/dashboards/[id]/screenshot/PUT.ts index 203c5409b..f359161c8 100644 --- a/apps/server/src/api/v2/dashboards/[id]/screenshot/PUT.ts +++ b/apps/server/src/api/v2/dashboards/[id]/screenshot/PUT.ts @@ -2,7 +2,7 @@ import { checkPermission } from '@buster/access-controls'; import { getDashboardById } from '@buster/database/queries'; import { AssetIdParamsSchema, - PutScreenshotRequestSchema, + PutDashboardScreenshotRequestSchema, type PutScreenshotResponse, } from '@buster/server-shared/screenshots'; import { zValidator } from '@hono/zod-validator'; @@ -12,7 +12,7 @@ import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-sc const app = new Hono().put( '/', - zValidator('json', PutScreenshotRequestSchema), + zValidator('json', PutDashboardScreenshotRequestSchema), zValidator('param', AssetIdParamsSchema), async (c) => { const assetId = c.req.valid('param').id; diff --git a/apps/server/src/api/v2/dictionaries/color-palettes/index.ts b/apps/server/src/api/v2/dictionaries/color-palettes/index.ts index 867a2747d..9276ae1cc 100644 --- a/apps/server/src/api/v2/dictionaries/color-palettes/index.ts +++ b/apps/server/src/api/v2/dictionaries/color-palettes/index.ts @@ -1,10 +1,11 @@ import type { ColorPaletteDictionariesResponse } from '@buster/server-shared/dictionary'; import { Hono } from 'hono'; +import { requireAuth } from '../../../../middleware/auth'; import { ALL_DICTIONARY_THEMES } from './config'; const app = new Hono(); -app.get('/', async (c) => { +app.get('/', requireAuth, async (c) => { const response: ColorPaletteDictionariesResponse = ALL_DICTIONARY_THEMES; return c.json(response); }); diff --git a/apps/server/src/api/v2/dictionaries/currency/index.ts b/apps/server/src/api/v2/dictionaries/currency/index.ts index 1b9eecb93..5b8636e2a 100644 --- a/apps/server/src/api/v2/dictionaries/currency/index.ts +++ b/apps/server/src/api/v2/dictionaries/currency/index.ts @@ -1,11 +1,13 @@ import type { CurrencyResponse } from '@buster/server-shared/dictionary'; import { Hono } from 'hono'; +import { requireAuth } from '../../../../middleware/auth'; import { CURRENCIES_MAP } from './config'; const app = new Hono(); -app.get('/', async (c) => { +app.get('/', requireAuth, async (c) => { const response: CurrencyResponse = CURRENCIES_MAP; + return c.json(response); }); diff --git a/apps/server/src/api/v2/metric_files/[id]/screenshot/GET.ts b/apps/server/src/api/v2/metric_files/[id]/screenshot/GET.ts index dc99fe910..605590faa 100644 --- a/apps/server/src/api/v2/metric_files/[id]/screenshot/GET.ts +++ b/apps/server/src/api/v2/metric_files/[id]/screenshot/GET.ts @@ -1,64 +1,67 @@ import { checkPermission } from '@buster/access-controls'; import { getMetricFileById } from '@buster/database/queries'; -import { getAssetScreenshotSignedUrl } from '@buster/search'; -import { AssetIdParamsSchema, type GetScreenshotResponse } from '@buster/server-shared/screenshots'; +import { + GetMetricScreenshotParamsSchema, + GetMetricScreenshotQuerySchema, +} from '@buster/server-shared/screenshots'; import { zValidator } from '@hono/zod-validator'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; +import { standardErrorHandler } from '../../../../../utils/response'; +import { getMetricScreenshotHandler } from './getMetricScreenshotHandler'; -const app = new Hono().get('/', zValidator('param', AssetIdParamsSchema), async (c) => { - const metricId = c.req.valid('param').id; - const user = c.get('busterUser'); +const app = new Hono() + .get( + '/', + zValidator('param', GetMetricScreenshotParamsSchema), + zValidator('query', GetMetricScreenshotQuerySchema), + async (c) => { + const metricId = c.req.valid('param').id; + const { version_number, width, height, type } = c.req.valid('query'); + const user = c.get('busterUser'); - const metric = await getMetricFileById(metricId); - if (!metric) { - throw new HTTPException(404, { message: 'Metric not found' }); - } + const metric = await getMetricFileById(metricId); + if (!metric) { + throw new HTTPException(404, { message: 'Metric not found' }); + } - if (!metric.screenshotBucketKey) { - const result: GetScreenshotResponse = { - success: false, - error: 'Screenshot not found', - }; - return c.json(result); - } + const permission = await checkPermission({ + userId: user.id, + assetId: metricId, + assetType: 'metric_file', + requiredRole: 'can_view', + workspaceSharing: metric.workspaceSharing, + organizationId: metric.organizationId, + }); - const permission = await checkPermission({ - userId: user.id, - assetId: metricId, - assetType: 'metric_file', - requiredRole: 'can_view', - workspaceSharing: metric.workspaceSharing, - organizationId: metric.organizationId, - }); + if (!permission.hasAccess) { + throw new HTTPException(403, { + message: 'You do not have permission to view this metric', + }); + } - if (!permission.hasAccess) { - throw new HTTPException(403, { - message: 'You do not have permission to view this metric', - }); - } + try { + const screenshotBuffer = await getMetricScreenshotHandler({ + params: { id: metricId }, + search: { version_number, width, height, type }, + context: c, + }); - try { - const signedUrl = await getAssetScreenshotSignedUrl({ - key: metric.screenshotBucketKey, - organizationId: metric.organizationId, - }); - const result: GetScreenshotResponse = { - success: true, - url: signedUrl, - }; - return c.json(result); - } catch (error) { - console.error('Failed to generate metric screenshot URL', { - metricId, - error, - }); - const result: GetScreenshotResponse = { - success: false, - error: 'Failed to generate screenshot URL', - }; - return c.json(result); - } -}); + return new Response(screenshotBuffer, { + headers: { + 'Content-Type': `image/${type}`, + 'Content-Length': screenshotBuffer.length.toString(), + }, + }); + } catch (error) { + console.error('Failed to generate metric image', { + metricId, + error, + }); + throw new Error('Failed to generate metric image'); + } + } + ) + .onError(standardErrorHandler); export default app; diff --git a/apps/server/src/api/v2/metric_files/[id]/screenshot/PUT.ts b/apps/server/src/api/v2/metric_files/[id]/screenshot/PUT.ts index 0643f70ee..0b81db85f 100644 --- a/apps/server/src/api/v2/metric_files/[id]/screenshot/PUT.ts +++ b/apps/server/src/api/v2/metric_files/[id]/screenshot/PUT.ts @@ -1,53 +1,52 @@ import { checkPermission } from '@buster/access-controls'; import { getMetricFileById } from '@buster/database/queries'; -import { - AssetIdParamsSchema, - PutScreenshotRequestSchema, - type PutScreenshotResponse, -} from '@buster/server-shared/screenshots'; +import { AssetIdParamsSchema, type PutScreenshotResponse } from '@buster/server-shared/screenshots'; import { zValidator } from '@hono/zod-validator'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-screenshot-handler'; +import { getMetricScreenshotHandler } from './getMetricScreenshotHandler'; -const app = new Hono().put( - '/', - zValidator('json', PutScreenshotRequestSchema), - zValidator('param', AssetIdParamsSchema), - async (c) => { - const assetId = c.req.valid('param').id; - const { base64Image } = c.req.valid('json'); - const user = c.get('busterUser'); +const app = new Hono().put('/', zValidator('param', AssetIdParamsSchema), async (c) => { + const assetId = c.req.valid('param').id; + const user = c.get('busterUser'); - const metric = await getMetricFileById(assetId); - if (!metric) { - throw new HTTPException(404, { message: 'Metric not found' }); - } - - const permission = await checkPermission({ - userId: user.id, - assetId, - assetType: 'metric_file', - requiredRole: 'can_edit', - workspaceSharing: metric.workspaceSharing, - organizationId: metric.organizationId, - }); - - if (!permission.hasAccess) { - throw new HTTPException(403, { - message: 'You do not have permission to upload a screenshot for this metric', - }); - } - - const result: PutScreenshotResponse = await uploadScreenshotHandler({ - assetType: 'metric_file', - assetId, - base64Image, - organizationId: metric.organizationId, - }); - - return c.json(result); + const metric = await getMetricFileById(assetId); + if (!metric) { + throw new HTTPException(404, { message: 'Metric not found' }); } -); + + const permission = await checkPermission({ + userId: user.id, + assetId, + assetType: 'metric_file', + requiredRole: 'can_edit', + workspaceSharing: metric.workspaceSharing, + organizationId: metric.organizationId, + }); + + if (!permission.hasAccess) { + throw new HTTPException(403, { + message: 'You do not have permission to upload a screenshot for this metric', + }); + } + + const screenshotBuffer = await getMetricScreenshotHandler({ + params: { metricId: assetId }, + search: { version_number: 1, width: 800, height: 600, type: 'png' }, + context: c, + }); + + const base64Image = screenshotBuffer.toString('base64'); + + const result: PutScreenshotResponse = await uploadScreenshotHandler({ + assetType: 'metric_file', + assetId, + base64Image, + organizationId: metric.organizationId, + }); + + return c.json(result); +}); export default app; diff --git a/apps/server/src/api/v2/metric_files/[id]/screenshot/getMetricScreenshotHandler.ts b/apps/server/src/api/v2/metric_files/[id]/screenshot/getMetricScreenshotHandler.ts new file mode 100644 index 000000000..31222c6b9 --- /dev/null +++ b/apps/server/src/api/v2/metric_files/[id]/screenshot/getMetricScreenshotHandler.ts @@ -0,0 +1,37 @@ +import type { + GetMetricScreenshotParams, + GetMetricScreenshotQuery, +} from '@buster/server-shared/screenshots'; +import type { Context } from 'hono'; +import { createHrefFromLink } from '../../../../../shared-helpers/create-href-from-link'; + +export const getMetricScreenshotHandler = async ({ + params, + search, + context, +}: { + params: GetMetricScreenshotParams; + search: GetMetricScreenshotQuery; + context: Context; +}) => { + const { width, height, type, version_number } = search; + const { id: metricId } = params; + + const { browserLogin } = await import('../../../../../shared-helpers/browser-login'); + + const { result: screenshotBuffer } = await browserLogin({ + width, + height, + fullPath: createHrefFromLink({ + to: '/screenshots/metrics/$metricId/content' as const, + params: { metricId }, + search: { version_number, type, width, height }, + }), + context, + callback: async ({ page }) => { + return await page.screenshot({ type }); + }, + }); + + return screenshotBuffer; +}; diff --git a/apps/server/src/api/v2/reports/[id]/screenshot/PUT.ts b/apps/server/src/api/v2/reports/[id]/screenshot/PUT.ts index 9fe543856..0bc273bea 100644 --- a/apps/server/src/api/v2/reports/[id]/screenshot/PUT.ts +++ b/apps/server/src/api/v2/reports/[id]/screenshot/PUT.ts @@ -2,7 +2,7 @@ import { checkPermission } from '@buster/access-controls'; import { getReportFileById } from '@buster/database/queries'; import { AssetIdParamsSchema, - PutScreenshotRequestSchema, + PutReportScreenshotRequestSchema, type PutScreenshotResponse, } from '@buster/server-shared/screenshots'; import { zValidator } from '@hono/zod-validator'; @@ -12,7 +12,7 @@ import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-sc const app = new Hono().put( '/', - zValidator('json', PutScreenshotRequestSchema), + zValidator('json', PutReportScreenshotRequestSchema), zValidator('param', AssetIdParamsSchema), async (c) => { const assetId = c.req.valid('param').id; diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts index 3706ceeaa..ca0271e01 100644 --- a/apps/server/src/middleware/auth.ts +++ b/apps/server/src/middleware/auth.ts @@ -7,7 +7,12 @@ import { getSupabaseClient } from './supabase'; export const requireAuth = bearerAuth({ verifyToken: async (token, c) => { try { - const { data, error } = await getSupabaseClient().auth.getUser(token); //usually takes about 3 - 7ms + const supabase = getSupabaseClient(); + const { data, error } = await supabase.auth.getUser(token); //usually takes about 3 - 7ms + const supabaseCookieKey = (supabase as unknown as { storageKey: string }).storageKey; + + c.set('supabaseCookieKey', supabaseCookieKey); + c.set('accessToken', token); if (error) { // Log specific auth errors to help with debugging diff --git a/apps/server/src/shared-helpers/browser-login.ts b/apps/server/src/shared-helpers/browser-login.ts new file mode 100644 index 000000000..d391cb41a --- /dev/null +++ b/apps/server/src/shared-helpers/browser-login.ts @@ -0,0 +1,93 @@ +import type { Context } from 'hono'; +import type { Browser, Page } from 'playwright'; + +export const browserLogin = async >({ + width, + height, + fullPath, + callback, + context, +}: { + width: number; + height: number; + fullPath: string; + callback: ({ page, browser }: { page: Page; browser: Browser }) => Promise; + context: Context; +}) => { + const supabaseUser = context.get('supabaseUser'); + const supabaseCookieKey = context.get('supabaseCookieKey'); + const accessToken = context.get('accessToken'); + + if (!accessToken) { + throw new Error('Missing Authorization header'); + } + + if (accessToken.split('.').length !== 3) { + throw new Error('Invalid access token format'); + } + + const jwtPayload = JSON.parse(Buffer.from(accessToken.split('.')[1] || '', 'base64').toString()); + + if (!supabaseUser || supabaseUser?.is_anonymous) { + throw new Error('User not authenticated'); + } + + const session = { + access_token: accessToken, + token_type: 'bearer', + expires_in: 3600, + expires_at: jwtPayload.exp, + refresh_token: '', + user: supabaseUser, + }; + + const { chromium } = await import('playwright'); + const browser = await chromium.launch(); + + try { + const context = await browser.newContext({ + viewport: { width, height }, + }); + + // Format cookie value as Supabase expects: base64- + const cookieValue = `base64-${Buffer.from(JSON.stringify(session)).toString('base64')}`; + + await context.addCookies([ + { + name: supabaseCookieKey, + value: cookieValue, + domain: new URL(process.env.VITE_PUBLIC_URL || {}).hostname, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + }, + ]); + + const page = await context.newPage(); + + let pageError: Error | null = null; + + page.on('console', (msg) => { + const text = msg.text(); + // React logs errors to console even when caught by error boundaries + if (msg.type() === 'error' && (text.includes('Error:') || text.includes('occurred in'))) { + pageError = new Error(`Page error: ${text}`); + } + }); + + await page.goto(fullPath, { waitUntil: 'networkidle' }); + + const result = await callback({ page, browser }); + + if (pageError) { + throw pageError; + } + + return { result }; + } catch (error) { + console.error('Error logging in to browser', error); + await browser.close(); + throw error; + } +}; diff --git a/apps/server/src/shared-helpers/create-href-from-link.test.ts b/apps/server/src/shared-helpers/create-href-from-link.test.ts new file mode 100644 index 000000000..a3586acb8 --- /dev/null +++ b/apps/server/src/shared-helpers/create-href-from-link.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createHrefFromLink } from './create-href-from-link'; + +describe('createHrefFromLink', () => { + const originalEnv = process.env.VITE_PUBLIC_URL; + + beforeEach(() => { + process.env.VITE_PUBLIC_URL = 'https://example.com'; + }); + + afterEach(() => { + process.env.VITE_PUBLIC_URL = originalEnv; + }); + + it('should replace single param placeholder with value', () => { + const result = createHrefFromLink({ + to: '/metrics/$metricId', + params: { metricId: '123' }, + }); + + expect(result).toBe('https://example.com/metrics/123'); + }); + + it('should replace multiple param placeholders with values', () => { + const result = createHrefFromLink({ + to: '/orgs/$orgId/metrics/$metricId', + params: { orgId: 'org-456', metricId: '123' }, + }); + + expect(result).toBe('https://example.com/orgs/org-456/metrics/123'); + }); + + it('should append query parameters', () => { + const result = createHrefFromLink({ + to: '/metrics/$metricId', + params: { metricId: '123' }, + search: { width: 800, height: 600 }, + }); + + expect(result).toBe('https://example.com/metrics/123?width=800&height=600'); + }); + + it('should handle string, number, and boolean query params', () => { + const result = createHrefFromLink({ + to: '/metrics/$metricId', + params: { metricId: '123' }, + search: { type: 'png', width: 800, fullscreen: true }, + }); + + expect(result).toBe('https://example.com/metrics/123?type=png&width=800&fullscreen=true'); + }); + + it('should filter out undefined query params', () => { + const result = createHrefFromLink({ + to: '/metrics/$metricId', + params: { metricId: '123' }, + search: { width: 800, height: undefined, type: 'png' }, + }); + + expect(result).toBe('https://example.com/metrics/123?width=800&type=png'); + }); + + it('should work without query params', () => { + const result = createHrefFromLink({ + to: '/metrics/$metricId', + params: { metricId: '123' }, + }); + + expect(result).toBe('https://example.com/metrics/123'); + }); + + it('should work with empty query params object', () => { + const result = createHrefFromLink({ + to: '/metrics/$metricId', + params: { metricId: '123' }, + search: {}, + }); + + expect(result).toBe('https://example.com/metrics/123'); + }); + + it('should handle paths without params', () => { + const result = createHrefFromLink({ + to: '/health', + params: {}, + search: { check: 'full' }, + }); + + expect(result).toBe('https://example.com/health?check=full'); + }); + + it('should work with empty base URL', () => { + process.env.VITE_PUBLIC_URL = ''; + + const result = createHrefFromLink({ + to: '/metrics/$metricId', + params: { metricId: '123' }, + }); + + expect(result).toBe('/metrics/123'); + }); + + it('should handle complex screenshot route', () => { + const result = createHrefFromLink({ + to: '/screenshots/metrics/$metricId/content', + params: { metricId: 'abc-123' }, + search: { version_number: 5, type: 'png', width: 1920, height: 1080 }, + }); + + expect(result).toBe( + 'https://example.com/screenshots/metrics/abc-123/content?version_number=5&type=png&width=1920&height=1080' + ); + }); +}); diff --git a/apps/server/src/shared-helpers/create-href-from-link.ts b/apps/server/src/shared-helpers/create-href-from-link.ts new file mode 100644 index 000000000..7e14d201d --- /dev/null +++ b/apps/server/src/shared-helpers/create-href-from-link.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +const CreateHrefFromLinkParamsSchema = z.object({ + to: z.string().describe('Route path with param placeholders like /path/$paramName'), + params: z.record(z.string()).describe('Object mapping param names to values'), + search: z.record(z.union([z.string(), z.number(), z.boolean(), z.undefined()])).optional().describe('Query parameters'), +}); + +type CreateHrefFromLinkParams = z.infer; + +/** + * Creates a full href from a route definition by: + * 1. Replacing $paramName placeholders with actual values + * 2. Appending query parameters + * 3. Prepending the public URL domain + */ +export function createHrefFromLink(input: CreateHrefFromLinkParams): string { + const { to, params, search } = CreateHrefFromLinkParamsSchema.parse(input); + + // Replace $paramName with actual param values + let path = to; + for (const [key, value] of Object.entries(params)) { + path = path.replace(`$${key}`, value); + } + + // Build query string from search params + const queryParams = new URLSearchParams(); + if (search) { + for (const [key, value] of Object.entries(search)) { + if (value !== undefined) { + queryParams.append(key, String(value)); + } + } + } + + const queryString = queryParams.toString(); + const fullPath = queryString ? `${path}?${queryString}` : path; + + // Get base URL from environment + const baseUrl = process.env.VITE_PUBLIC_URL || ''; + + return `${baseUrl}${fullPath}`; +} diff --git a/apps/server/src/shared-helpers/upload-screenshot-handler.ts b/apps/server/src/shared-helpers/upload-screenshot-handler.ts index 64d2db26f..187b13c87 100644 --- a/apps/server/src/shared-helpers/upload-screenshot-handler.ts +++ b/apps/server/src/shared-helpers/upload-screenshot-handler.ts @@ -3,13 +3,13 @@ import { updateAssetScreenshotBucketKey } from '@buster/database/queries'; import type { AssetType } from '@buster/server-shared/assets'; import { AssetTypeSchema } from '@buster/server-shared/assets'; import { - PutScreenshotRequestSchema, + PutChatScreenshotRequestSchema, type PutScreenshotResponse, PutScreenshotResponseSchema, } from '@buster/server-shared/screenshots'; import z from 'zod'; -export const UploadScreenshotParamsSchema = PutScreenshotRequestSchema.extend({ +export const UploadScreenshotParamsSchema = PutChatScreenshotRequestSchema.extend({ assetType: AssetTypeSchema, assetId: z.string().uuid('Asset ID must be a valid UUID'), organizationId: z.string().uuid('Organization ID must be a valid UUID'), diff --git a/apps/server/src/types/hono.types.ts b/apps/server/src/types/hono.types.ts index 5b1bccbda..e9e4a548c 100644 --- a/apps/server/src/types/hono.types.ts +++ b/apps/server/src/types/hono.types.ts @@ -6,6 +6,12 @@ import type { User } from '@supabase/supabase-js'; declare module 'hono' { interface ContextVariableMap { + /** + * The Supabase cookie key. This is used to set the cookie in the browser. + * It is the cookie that supabase uses to store the user's session. + * We use it on the server for playwright auth setting + */ + readonly supabaseCookieKey: string; /** * The authenticated Supabase user. This object is readonly to prevent accidental mutation. */ @@ -31,5 +37,9 @@ declare module 'hono' { * API key context for public API endpoints. Set by the createApiKeyAuthMiddleware. */ readonly apiKey?: ApiKeyContext; + /** + * The access token for the user. Set by the requireAuth middleware. + */ + readonly accessToken?: string; } } diff --git a/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx b/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx index d7ee125c7..356541f7d 100644 --- a/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx +++ b/apps/web/src/routes/screenshots/metrics.$metricId.index.tsx @@ -28,30 +28,23 @@ export const Route = createFileRoute('/screenshots/metrics/$metricId/')({ const { result: screenshotBuffer } = await browserLogin({ width, height, + request, fullPath: createHrefFromLink({ to: '/screenshots/metrics/$metricId/content', params: { metricId }, search: { version_number, type, width, height }, }), - request, callback: async ({ page }) => { - const screenshotBuffer = await page.screenshot({ + return await page.screenshot({ type, }); - - return screenshotBuffer; }, }); return createScreenshotResponse({ screenshotBuffer }); } catch (error) { - console.error('Error capturing metric screenshot', error); - return new Response( - JSON.stringify({ - message: 'Failed to capture screenshot', - }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); + console.error('Error generating metric image', error); + throw Error('Failed to generate metric image'); } }, }, diff --git a/packages/server-shared/src/screenshots/index.ts b/packages/server-shared/src/screenshots/index.ts index a7007335b..94dcc201b 100644 --- a/packages/server-shared/src/screenshots/index.ts +++ b/packages/server-shared/src/screenshots/index.ts @@ -1 +1,5 @@ export * from './screenshots'; +export * from './requests.metrics'; +export * from './requests.dashboards'; +export * from './requests.reports'; +export * from './requests.chats'; diff --git a/packages/server-shared/src/screenshots/requests.chats.ts b/packages/server-shared/src/screenshots/requests.chats.ts new file mode 100644 index 000000000..94e005869 --- /dev/null +++ b/packages/server-shared/src/screenshots/requests.chats.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const PutChatScreenshotRequestSchema = z.object({ + base64Image: z.string(), +}); + +export type PutChatScreenshotRequest = z.infer; diff --git a/packages/server-shared/src/screenshots/requests.dashboards.ts b/packages/server-shared/src/screenshots/requests.dashboards.ts new file mode 100644 index 000000000..45d8bda22 --- /dev/null +++ b/packages/server-shared/src/screenshots/requests.dashboards.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const PutDashboardScreenshotRequestSchema = z.object({ + base64Image: z.string(), +}); + +export type PutDashboardScreenshotRequest = z.infer; diff --git a/packages/server-shared/src/screenshots/requests.metrics.ts b/packages/server-shared/src/screenshots/requests.metrics.ts new file mode 100644 index 000000000..6eb130c3e --- /dev/null +++ b/packages/server-shared/src/screenshots/requests.metrics.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const GetMetricScreenshotParamsSchema = z.object({ + id: z.string(), +}); + +export const GetMetricScreenshotQuerySchema = z.object({ + version_number: z.coerce.number().min(1).optional(), + width: z.coerce.number().min(100).max(3840).default(800), + height: z.coerce.number().min(100).max(2160).default(450), + type: z.enum(['png', 'jpeg']).default('png'), +}); + +export type GetMetricScreenshotParams = z.infer; +export type GetMetricScreenshotQuery = z.infer; + +export const PutMetricScreenshotRequestSchema = z.object({ + base64Image: z.string(), +}); + +export type PutMetricScreenshotRequest = z.infer; diff --git a/packages/server-shared/src/screenshots/requests.reports.ts b/packages/server-shared/src/screenshots/requests.reports.ts new file mode 100644 index 000000000..2fbf510d2 --- /dev/null +++ b/packages/server-shared/src/screenshots/requests.reports.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const PutReportScreenshotRequestSchema = z.object({ + base64Image: z.string(), +}); + +export type PutReportScreenshotRequest = z.infer; diff --git a/packages/server-shared/src/screenshots/screenshots.ts b/packages/server-shared/src/screenshots/screenshots.ts index f915cab32..19742a1c9 100644 --- a/packages/server-shared/src/screenshots/screenshots.ts +++ b/packages/server-shared/src/screenshots/screenshots.ts @@ -4,12 +4,6 @@ export const AssetIdParamsSchema = z.object({ id: z.string().uuid('Asset ID must be a valid UUID'), }); -export const PutScreenshotRequestSchema = z.object({ - base64Image: z.string().min(1, 'Base64 image is required'), -}); - -export type PutScreenshotRequest = z.infer; - export const PutScreenshotResponseSchema = z.object({ success: z.boolean(), bucketKey: z.string().min(1, 'Bucket key is required'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65b5ade88..9f5cfb7a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,9 @@ importers: pino-pretty: specifier: ^13.1.1 version: 13.1.1 + playwright: + specifier: ^1.55.1 + version: 1.55.1 tsup: specifier: 'catalog:' version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.5)(typescript@5.9.2)(yaml@2.8.1) @@ -18833,7 +18836,7 @@ snapshots: magic-string: 0.30.17 sirv: 3.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) ws: 8.18.3 optionalDependencies: playwright: 1.55.1 @@ -18878,7 +18881,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) optionalDependencies: '@vitest/browser': 3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4) transitivePeerDependencies: