/** * Main API Worker for Rocky Mountain Vending * * This worker handles all API endpoints: * - /api/manuals/* - Serve PDF manuals from R2 * - /api/thumbnails/* - Serve thumbnail images from R2 * - /api/sitemap-submit - Submit sitemap to Google Search Console * - /api/request-indexing - Request URL indexing * - /health - Health check endpoint * * Account ID: bd6f76304a840ba11b75f9ced84264f4 * Temp subdomain: matt-bd6.workers.dev */ export interface Env { // R2 bucket bindings MANUALS_BUCKET: R2Bucket; THUMBNAILS_BUCKET: R2Bucket; // Google Search Console credentials (optional) GOOGLE_SERVICE_ACCOUNT_EMAIL?: string; GOOGLE_PRIVATE_KEY?: string; GOOGLE_SITE_URL?: string; // Site configuration SITE_URL?: string; SITE_DOMAIN?: string; // CORS configuration ALLOWED_ORIGINS?: string; } const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }; export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const pathname = url.pathname; // Handle CORS preflight if (request.method === 'OPTIONS') { return new Response(null, { headers: CORS_HEADERS, }); } // Route requests if (pathname.startsWith('/api/manuals/')) { return handleManuals(request, env, url); } if (pathname.startsWith('/api/thumbnails/')) { return handleThumbnails(request, env, url); } if (pathname === '/api/sitemap-submit') { return handleSitemapSubmit(request, env); } if (pathname === '/api/request-indexing') { return handleRequestIndexing(request, env); } if (pathname === '/health') { return handleHealth(env); } return new Response('Not found', { status: 404, headers: CORS_HEADERS, }); }, }; /** * Handle manual PDF requests from R2 */ async function handleManuals(request: Request, env: Env, url: URL): Promise { if (request.method !== 'GET') { return new Response('Method not allowed', { status: 405, headers: CORS_HEADERS, }); } // Extract path from /api/manuals/... const pathMatch = url.pathname.match(/^\/api\/manuals\/(.+)$/); if (!pathMatch) { return new Response('Invalid path', { status: 400, headers: CORS_HEADERS, }); } const filePath = decodeURIComponent(pathMatch[1]); // Security: Prevent directory traversal if (filePath.includes('..') || filePath.startsWith('/')) { return new Response('Invalid path', { status: 400, headers: CORS_HEADERS, }); } // Only allow PDF files if (!filePath.toLowerCase().endsWith('.pdf')) { return new Response('Invalid file type', { status: 400, headers: CORS_HEADERS, }); } try { const object = await env.MANUALS_BUCKET.get(filePath); if (!object) { return new Response('File not found', { status: 404, headers: CORS_HEADERS, }); } const filename = filePath.split('/').pop() || 'manual.pdf'; const headers = new Headers(CORS_HEADERS); headers.set('Content-Type', 'application/pdf'); headers.set('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`); headers.set('Cache-Control', 'public, max-age=31536000, immutable'); headers.set('X-Content-Type-Options', 'nosniff'); return new Response(object.body, { headers }); } catch (error) { console.error('Error serving manual:', error); return new Response( JSON.stringify({ error: 'Failed to serve manual', message: error instanceof Error ? error.message : 'Unknown error' }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } } /** * Handle thumbnail image requests from R2 */ async function handleThumbnails(request: Request, env: Env, url: URL): Promise { if (request.method !== 'GET') { return new Response('Method not allowed', { status: 405, headers: CORS_HEADERS, }); } // Extract path from /api/thumbnails/... const pathMatch = url.pathname.match(/^\/api\/thumbnails\/(.+)$/); if (!pathMatch) { return new Response('Invalid path', { status: 400, headers: CORS_HEADERS, }); } const filePath = decodeURIComponent(pathMatch[1]); // Security: Prevent directory traversal if (filePath.includes('..') || filePath.startsWith('/')) { return new Response('Invalid path', { status: 400, headers: CORS_HEADERS, }); } // Only allow image files const lowerPath = filePath.toLowerCase(); const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp']; if (!allowedExtensions.some(ext => lowerPath.endsWith(ext))) { return new Response('Invalid file type', { status: 400, headers: CORS_HEADERS, }); } try { const object = await env.THUMBNAILS_BUCKET.get(filePath); if (!object) { return new Response('Thumbnail not found', { status: 404, headers: CORS_HEADERS, }); } const filename = filePath.split('/').pop() || 'thumbnail.jpg'; const contentType = lowerPath.endsWith('.png') ? 'image/png' : lowerPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg'; const headers = new Headers(CORS_HEADERS); headers.set('Content-Type', contentType); headers.set('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`); headers.set('Cache-Control', 'public, max-age=31536000, immutable'); headers.set('X-Content-Type-Options', 'nosniff'); return new Response(object.body, { headers }); } catch (error) { console.error('Error serving thumbnail:', error); return new Response( JSON.stringify({ error: 'Failed to serve thumbnail', message: error instanceof Error ? error.message : 'Unknown error' }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } } /** * Handle sitemap submission to Google Search Console */ async function handleSitemapSubmit(request: Request, env: Env): Promise { if (request.method === 'GET') { const siteUrl = env.SITE_URL || env.GOOGLE_SITE_URL || 'https://rockymountainvending.com'; const sitemapUrl = `${siteUrl}/sitemap.xml`; return new Response( JSON.stringify({ message: 'Google Search Console Sitemap Submission API', sitemapUrl, configured: !!(env.GOOGLE_SERVICE_ACCOUNT_EMAIL && env.GOOGLE_PRIVATE_KEY), instructions: 'POST to this endpoint with optional sitemapUrl and siteUrl in body', }), { headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405, headers: CORS_HEADERS, }); } try { const body = await request.json() as { sitemapUrl?: string; siteUrl?: string }; const siteUrl = body.siteUrl || env.SITE_URL || env.GOOGLE_SITE_URL || 'https://rockymountainvending.com'; const sitemapUrl = body.sitemapUrl || `${siteUrl}/sitemap.xml`; if (!env.GOOGLE_SERVICE_ACCOUNT_EMAIL || !env.GOOGLE_PRIVATE_KEY) { return new Response( JSON.stringify({ success: false, error: 'Google Search Console credentials not configured', message: 'Please set GOOGLE_SERVICE_ACCOUNT_EMAIL and GOOGLE_PRIVATE_KEY environment variables', }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } // Note: Full Google Search Console API integration would require googleapis library // For now, this is a placeholder that returns success // To implement fully, you would need to: // 1. Install googleapis in the worker (may require bundling) // 2. Use JWT authentication with service account // 3. Call searchconsole.sitemaps.submit() return new Response( JSON.stringify({ success: true, message: 'Sitemap submission endpoint ready', sitemapUrl, siteUrl, note: 'Google Search Console API integration requires additional setup. See documentation.', }), { headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } catch (error) { return new Response( JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } } /** * Handle URL indexing requests */ async function handleRequestIndexing(request: Request, env: Env): Promise { if (request.method === 'GET') { return new Response( JSON.stringify({ message: 'Google Search Console URL Indexing Request API', configured: !!(env.GOOGLE_SERVICE_ACCOUNT_EMAIL && env.GOOGLE_PRIVATE_KEY), instructions: "POST to this endpoint with { url: 'https://example.com/page' } in body", }), { headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405, headers: CORS_HEADERS, }); } try { const body = await request.json() as { url?: string }; const { url } = body; if (!url) { return new Response( JSON.stringify({ success: false, error: 'URL is required', }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } if (!env.GOOGLE_SERVICE_ACCOUNT_EMAIL || !env.GOOGLE_PRIVATE_KEY) { return new Response( JSON.stringify({ success: false, error: 'Google Search Console credentials not configured', message: 'Please set GOOGLE_SERVICE_ACCOUNT_EMAIL and GOOGLE_PRIVATE_KEY environment variables', }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } // Note: Full Google Search Console API integration would require googleapis library // For now, this is a placeholder that returns success // To implement fully, you would need to: // 1. Install googleapis in the worker (may require bundling) // 2. Use JWT authentication with service account // 3. Call searchconsole.urlInspection.index.inspect() return new Response( JSON.stringify({ success: true, message: 'Indexing request endpoint ready', url, note: 'Google Search Console API integration requires additional setup. See documentation.', }), { headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } catch (error) { return new Response( JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } } /** * Health check endpoint */ function handleHealth(env: Env): Response { return new Response( JSON.stringify({ status: 'ok', timestamp: new Date().toISOString(), buckets: { manuals: !!env.MANUALS_BUCKET, thumbnails: !!env.THUMBNAILS_BUCKET, }, }), { headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); }