Rocky_Mountain_Vending/workers/api-worker/index.ts

451 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" },
}
)
}