Rocky_Mountain_Vending/workers/r2-signed-urls/index.ts

188 lines
5.5 KiB
TypeScript

/**
* Cloudflare Worker for generating signed URLs for R2 objects
*
* This worker provides secure access to private R2 buckets by generating
* time-limited signed URLs.
*
* Endpoint: /signed-url?file=<path>&bucket=<bucket>&expires=<seconds>
*
* Example:
* GET /signed-url?file=manuals/Crane/manual.pdf&bucket=vending-vm-manuals&expires=3600
*
* Returns: { url: "https://signed-url..." }
*/
export interface Env {
// R2 bucket bindings (configured in wrangler.toml)
MANUALS_BUCKET?: R2Bucket
THUMBNAILS_BUCKET?: R2Bucket
// Default bucket if not specified
DEFAULT_BUCKET?: R2Bucket
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// Only allow GET requests
if (request.method !== "GET") {
return new Response("Method not allowed", { status: 405 })
}
// Handle signed URL generation
if (url.pathname === "/signed-url") {
return handleSignedUrl(request, env, url)
}
// Health check endpoint
if (url.pathname === "/health") {
return new Response(
JSON.stringify({ status: "ok", timestamp: new Date().toISOString() }),
{
headers: { "Content-Type": "application/json" },
}
)
}
return new Response("Not found", { status: 404 })
},
}
/**
* Generate a signed URL for an R2 object
*/
async function handleSignedUrl(
request: Request,
env: Env,
url: URL
): Promise<Response> {
const filePath = url.searchParams.get("file")
const bucketName = url.searchParams.get("bucket") || "manuals"
const expiresIn = parseInt(url.searchParams.get("expires") || "3600", 10) // Default 1 hour
if (!filePath) {
return new Response(
JSON.stringify({ error: "file parameter is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
// Validate expires parameter
if (isNaN(expiresIn) || expiresIn < 60 || expiresIn > 604800) {
return new Response(
JSON.stringify({
error:
"expires must be between 60 and 604800 seconds (1 minute to 7 days)",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
// Get the appropriate bucket
let bucket: R2Bucket | undefined
if (bucketName === "manuals" && env.MANUALS_BUCKET) {
bucket = env.MANUALS_BUCKET
} else if (bucketName === "thumbnails" && env.THUMBNAILS_BUCKET) {
bucket = env.THUMBNAILS_BUCKET
} else if (env.DEFAULT_BUCKET) {
bucket = env.DEFAULT_BUCKET
} else {
return new Response(
JSON.stringify({
error: `Bucket '${bucketName}' not found or not configured`,
}),
{ status: 404, headers: { "Content-Type": "application/json" } }
)
}
try {
// Generate signed URL
// Note: R2 signed URLs are generated using the bucket's presigned URL method
// This is a simplified example - actual implementation may vary based on R2 API
const signedUrl = await bucket.createMultipartUpload(filePath, {
httpMetadata: {
contentType: getContentType(filePath),
},
})
// For R2, we need to use the R2 API to generate presigned URLs
// This is a placeholder - actual R2 signed URL generation may require
// using the R2 API directly or a different method
const object = await bucket.get(filePath)
if (!object) {
return new Response(JSON.stringify({ error: "File not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
}
// Generate presigned URL (this is a simplified example)
// In production, you would use R2's actual presigned URL API
const presignedUrl = await generatePresignedUrl(bucket, filePath, expiresIn)
return new Response(
JSON.stringify({
url: presignedUrl,
expiresIn,
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
}),
{
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET",
},
}
)
} catch (error) {
console.error("Error generating signed URL:", error)
return new Response(
JSON.stringify({
error: "Failed to generate signed URL",
message: error instanceof Error ? error.message : "Unknown error",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
)
}
}
/**
* Generate a presigned URL for R2 object
* Note: This is a placeholder - actual implementation depends on R2 API
*/
async function generatePresignedUrl(
bucket: R2Bucket,
key: string,
expiresIn: number
): Promise<string> {
// R2 doesn't have a direct presigned URL API like S3
// You would need to use R2's public URL with custom domain or
// implement a custom signing mechanism
//
// For now, this returns a placeholder URL
// In production, you would:
// 1. Use R2's public bucket URL if bucket is public
// 2. Or implement custom signing using R2's API
// 3. Or use a custom domain with R2
throw new Error(
"Presigned URL generation not yet implemented. Use public buckets or implement custom signing."
)
}
/**
* Get content type based on file extension
*/
function getContentType(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase()
const types: Record<string, string> = {
pdf: "application/pdf",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
}
return types[ext || ""] || "application/octet-stream"
}