451 lines
12 KiB
TypeScript
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" },
|
|
}
|
|
)
|
|
}
|