Next.js website for Rocky Mountain Vending company featuring: - Product catalog with Stripe integration - Service areas and parts pages - Admin dashboard with Clerk authentication - SEO optimized pages with JSON-LD structured data Co-authored-by: Cursor <cursoragent@cursor.com>
423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
/**
|
|
* 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<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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' },
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|