Advertisement

← vibesdk

worker/api/controllers/screenshots/controller.ts

4.3 KB Download file
import { BaseController } from '../baseController';
import type { ControllerResponse, ApiResponse } from '../types';
import type { RouteContext } from '../../types/route-context';
import { createLogger } from '../../../logger';
import { ScreenshotSecurity } from '../../../utils/screenshot-security';

// -------------------------
// Helpers
// -------------------------
function isValidAppId(id: string): boolean {
    // Allow alphanumeric, underscore, dash. Prevent dots and slashes.
    // Length 1-128.
    return /^[A-Za-z0-9_-]{1,128}$/.test(id);
}

function validateFileName(file: string): string | null {
    // Reject any traversal or path separators
    if (file.includes('..') || file.includes('/') || file.includes('\\') || file.includes('\0')) {
        return null;
    }
    // Enforce simple filename pattern
    if (!/^[A-Za-z0-9._-]{1,128}$/.test(file)) {
        return null;
    }
    // Disallow leading dot files
    if (file.startsWith('.')) {
        return null;
    }
    // Validate extension
    const extIndex = file.lastIndexOf('.');
    if (extIndex <= 0 || extIndex === file.length - 1) {
        return null;
    }
    const ext = file.substring(extIndex + 1).toLowerCase();
    const allowed = new Set(['png', 'jpg', 'jpeg', 'webp']);
    if (!allowed.has(ext)) {
        return null;
    }
    return file;
}

function getMimeByExtension(file: string): string | undefined {
    const ext = file.substring(file.lastIndexOf('.') + 1).toLowerCase();
    switch (ext) {
        case 'png': return 'image/png';
        case 'jpg':
        case 'jpeg': return 'image/jpeg';
        case 'webp': return 'image/webp';
        default: return undefined;
    }
}
export class ScreenshotsController extends BaseController {
    static logger = createLogger('ScreenshotsController');
    static async serveScreenshot(
        request: Request,
        env: Env,
        _ctx: ExecutionContext,
        context: RouteContext,
    ): Promise<ControllerResponse<ApiResponse<never>>> {
        try {
            const appId = context.pathParams.id;
            const file = context.pathParams.file;

            if (!appId || !file) {
                return ScreenshotsController.createErrorResponse('Missing path parameters', 400);
            }

            // Validate and sanitize path parameters
            if (!isValidAppId(appId)) {
                return ScreenshotsController.createErrorResponse('Invalid app id', 400);
            }

            const validatedFile = validateFileName(file);
            if (!validatedFile) {
                return ScreenshotsController.createErrorResponse('Invalid file name', 400);
            }

            // Verify screenshot access token
            const url = new URL(request.url);
            const token = url.searchParams.get('token');

            if (!token) {
                return ScreenshotsController.createErrorResponse('Screenshot not found', 404);
            }

            const security = new ScreenshotSecurity(env);
            const isValidToken = await security.verifyToken(token, appId);
            if (!isValidToken) {
                return ScreenshotsController.createErrorResponse('Screenshot not found', 404);
            }

            const key = `screenshots/${appId}/${validatedFile}`;
            const obj = await env.TEMPLATES_BUCKET.get(key);
            if (!obj || !obj.body) {
                return ScreenshotsController.createErrorResponse('Screenshot not found', 404);
            }

            const contentType = obj.httpMetadata?.contentType || getMimeByExtension(validatedFile) || 'image/png';
            const headers = new Headers({
                'Content-Type': contentType,
                'Cache-Control': 'public, max-age=21600',
                'X-Content-Type-Options': 'nosniff',
            });

			// We return a naked Response because our controller helper types expect JSON, but this route is binary.
			// It's safe because the router uses this Response directly.
			return new Response(obj.body, {
				headers,
			}) as unknown as ControllerResponse<ApiResponse<never>>;
		        } catch (error) {
            this.logger.error('Error serving screenshot', { error });
            return ScreenshotsController.createErrorResponse('Internal server error', 500);
        }
    }
}