mirror of https://github.com/buster-so/buster.git
Move screenshots to the server pt 1
This commit is contained in:
parent
9b780e03c4
commit
721b20af8a
|
@ -15,6 +15,7 @@ declare global {
|
|||
SLACK_APP_SUPPORT_URL: string;
|
||||
SERVER_URL: string;
|
||||
NODE_ENV?: 'development' | 'production' | 'test';
|
||||
VITE_PUBLIC_URL: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
import type { Context } from 'hono';
|
||||
import type { Browser, Page } from 'playwright';
|
||||
|
||||
export const browserLogin = async <T = Buffer<ArrayBufferLike>>({
|
||||
width,
|
||||
height,
|
||||
fullPath,
|
||||
callback,
|
||||
context,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
fullPath: string;
|
||||
callback: ({ page, browser }: { page: Page; browser: Browser }) => Promise<T>;
|
||||
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-<encoded_session>
|
||||
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;
|
||||
}
|
||||
};
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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<typeof CreateHrefFromLinkParamsSchema>;
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
|
@ -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'),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1 +1,5 @@
|
|||
export * from './screenshots';
|
||||
export * from './requests.metrics';
|
||||
export * from './requests.dashboards';
|
||||
export * from './requests.reports';
|
||||
export * from './requests.chats';
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const PutChatScreenshotRequestSchema = z.object({
|
||||
base64Image: z.string(),
|
||||
});
|
||||
|
||||
export type PutChatScreenshotRequest = z.infer<typeof PutChatScreenshotRequestSchema>;
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const PutDashboardScreenshotRequestSchema = z.object({
|
||||
base64Image: z.string(),
|
||||
});
|
||||
|
||||
export type PutDashboardScreenshotRequest = z.infer<typeof PutDashboardScreenshotRequestSchema>;
|
|
@ -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<typeof GetMetricScreenshotParamsSchema>;
|
||||
export type GetMetricScreenshotQuery = z.infer<typeof GetMetricScreenshotQuerySchema>;
|
||||
|
||||
export const PutMetricScreenshotRequestSchema = z.object({
|
||||
base64Image: z.string(),
|
||||
});
|
||||
|
||||
export type PutMetricScreenshotRequest = z.infer<typeof PutMetricScreenshotRequestSchema>;
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const PutReportScreenshotRequestSchema = z.object({
|
||||
base64Image: z.string(),
|
||||
});
|
||||
|
||||
export type PutReportScreenshotRequest = z.infer<typeof PutReportScreenshotRequestSchema>;
|
|
@ -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<typeof PutScreenshotRequestSchema>;
|
||||
|
||||
export const PutScreenshotResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
bucketKey: z.string().min(1, 'Bucket key is required'),
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue