mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 17:31:13 -05:00
Migrate 8 high-priority functions (admin-delete-user, mfa-unenroll, confirm-account-deletion, request-account-deletion, send-contact-message, upload-image, validate-email-backend, process-oauth-profile) to wrapEdgeFunction pattern. Replace manual CORS/auth, add shared validations, integrate standardized error handling, and preserve existing rate limiting where applicable. Update implementations to leverage context span, requestId, and improved logging for consistent error reporting and tracing.
354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|
import { getAllowedOrigin, getCorsHeaders } from '../_shared/cors.ts'
|
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'
|
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts'
|
|
import { validateString } from '../_shared/typeValidation.ts'
|
|
import { addSpanEvent } from '../_shared/logger.ts'
|
|
|
|
// Helper to create authenticated Supabase client
|
|
const createAuthenticatedSupabaseClient = (authHeader: string) => {
|
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
|
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')
|
|
|
|
if (!supabaseUrl || !supabaseAnonKey) {
|
|
throw new Error('Missing Supabase environment variables')
|
|
}
|
|
|
|
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
global: { headers: { Authorization: authHeader } }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Report ban evasion attempts to system alerts
|
|
*/
|
|
async function reportBanEvasionToAlerts(
|
|
supabaseClient: any,
|
|
userId: string,
|
|
action: string,
|
|
requestId: string
|
|
): Promise<void> {
|
|
try {
|
|
await supabaseClient.rpc('create_system_alert', {
|
|
p_alert_type: 'ban_attempt',
|
|
p_severity: 'high',
|
|
p_message: `Banned user attempted image upload: ${action}`,
|
|
p_metadata: {
|
|
user_id: userId,
|
|
action,
|
|
request_id: requestId,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
});
|
|
} catch (error) {
|
|
// Non-blocking - log but don't fail the response
|
|
}
|
|
}
|
|
|
|
// Apply strict rate limiting (5 requests/minute) to prevent abuse
|
|
const handler = createEdgeFunction(
|
|
{
|
|
name: 'upload-image',
|
|
requireAuth: false, // Auth checked per-method
|
|
corsHeaders: {} // Dynamic CORS
|
|
},
|
|
async (req, context) => {
|
|
const requestOrigin = req.headers.get('origin');
|
|
const allowedOrigin = getAllowedOrigin(requestOrigin);
|
|
|
|
// Check if this is a CORS request with a disallowed origin
|
|
if (requestOrigin && !allowedOrigin) {
|
|
addSpanEvent(context.span, 'cors_rejected', { origin: requestOrigin });
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Origin not allowed',
|
|
message: 'The origin of this request is not allowed to access this resource'
|
|
}),
|
|
{
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
}
|
|
|
|
const corsHeaders = getCorsHeaders(allowedOrigin);
|
|
context.span.setAttribute('http_method', req.method);
|
|
context.span.setAttribute('action', 'upload_image');
|
|
|
|
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID')
|
|
const CLOUDFLARE_IMAGES_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN')
|
|
|
|
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_IMAGES_API_TOKEN) {
|
|
throw new Error('Missing Cloudflare credentials')
|
|
}
|
|
|
|
if (req.method === 'DELETE') {
|
|
// Require authentication for DELETE operations
|
|
const authHeader = req.headers.get('Authorization')
|
|
if (!authHeader) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Authentication required',
|
|
message: 'Authentication required for delete operations'
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
|
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) {
|
|
throw new Error('Invalid authentication');
|
|
}
|
|
|
|
context.span.setAttribute('user_id', user.id);
|
|
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
if (profile?.banned) {
|
|
await reportBanEvasionToAlerts(supabase, user.id, 'image_delete', context.requestId);
|
|
addSpanEvent(context.span, 'banned_user_blocked', { action: 'delete' });
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Account suspended',
|
|
message: 'Account suspended. Contact support for assistance.'
|
|
}),
|
|
{
|
|
status: 403,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
// Delete image from Cloudflare
|
|
const requestBody = await req.json();
|
|
const { imageId } = requestBody;
|
|
|
|
validateString(imageId, 'imageId', { userId: user.id, requestId: context.requestId });
|
|
|
|
// Validate imageId format
|
|
const validImageIdPattern = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
if (!validImageIdPattern.test(imageId)) {
|
|
throw new Error('Invalid imageId format - must be alphanumeric with optional hyphens/underscores (max 100 chars)');
|
|
}
|
|
|
|
addSpanEvent(context.span, 'delete_image_start', { imageId });
|
|
|
|
const deleteResponse = await fetch(
|
|
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
|
},
|
|
}
|
|
)
|
|
|
|
const deleteResult = await deleteResponse.json()
|
|
|
|
if (!deleteResponse.ok) {
|
|
throw new Error(deleteResult.errors?.[0]?.message || deleteResult.error || 'Failed to delete image');
|
|
}
|
|
|
|
addSpanEvent(context.span, 'image_deleted', { imageId });
|
|
return new Response(
|
|
JSON.stringify({ success: true, deleted: true }),
|
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
if (req.method === 'POST') {
|
|
// Require authentication for POST operations
|
|
const authHeader = req.headers.get('Authorization')
|
|
if (!authHeader) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Authentication required',
|
|
message: 'Authentication required for upload operations'
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
|
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) {
|
|
throw new Error('Invalid authentication');
|
|
}
|
|
|
|
context.span.setAttribute('user_id', user.id);
|
|
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
if (profile?.banned) {
|
|
await reportBanEvasionToAlerts(supabase, user.id, 'image_upload', context.requestId);
|
|
addSpanEvent(context.span, 'banned_user_blocked', { action: 'upload' });
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Account suspended',
|
|
message: 'Account suspended. Contact support for assistance.'
|
|
}),
|
|
{
|
|
status: 403,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
// Request a direct upload URL from Cloudflare
|
|
let requestBody;
|
|
try {
|
|
requestBody = await req.json();
|
|
} catch (error: unknown) {
|
|
requestBody = {};
|
|
}
|
|
|
|
const { metadata = {}, variant = 'public', requireSignedURLs = false } = requestBody;
|
|
|
|
addSpanEvent(context.span, 'upload_url_request_start');
|
|
|
|
// Create FormData for the request
|
|
const formData = new FormData()
|
|
formData.append('requireSignedURLs', requireSignedURLs.toString())
|
|
|
|
if (metadata && Object.keys(metadata).length > 0) {
|
|
formData.append('metadata', JSON.stringify(metadata))
|
|
}
|
|
|
|
const directUploadResponse = await fetch(
|
|
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
|
},
|
|
body: formData,
|
|
}
|
|
)
|
|
|
|
const directUploadResult = await directUploadResponse.json()
|
|
|
|
if (!directUploadResponse.ok) {
|
|
throw new Error(directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL');
|
|
}
|
|
|
|
addSpanEvent(context.span, 'upload_url_created', { imageId: directUploadResult.result.id });
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
uploadURL: directUploadResult.result.uploadURL,
|
|
id: directUploadResult.result.id,
|
|
}),
|
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
// Require authentication for GET operations
|
|
const authHeader = req.headers.get('Authorization')
|
|
if (!authHeader) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Authentication required',
|
|
message: 'Authentication required for image status operations'
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
|
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) {
|
|
throw new Error('Invalid authentication');
|
|
}
|
|
|
|
context.span.setAttribute('user_id', user.id);
|
|
|
|
// Check image status endpoint
|
|
const url = new URL(req.url)
|
|
const imageId = url.searchParams.get('id')
|
|
|
|
validateString(imageId, 'id', { userId: user.id, requestId: context.requestId });
|
|
|
|
addSpanEvent(context.span, 'get_image_status_start', { imageId });
|
|
|
|
const imageResponse = await fetch(
|
|
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
|
},
|
|
}
|
|
)
|
|
|
|
const imageResult = await imageResponse.json()
|
|
|
|
if (!imageResponse.ok) {
|
|
throw new Error(imageResult.errors?.[0]?.message || imageResult.error || 'Unable to retrieve image information');
|
|
}
|
|
|
|
// Return the image details with convenient URLs
|
|
const result = imageResult.result
|
|
const baseUrl = `https://cdn.thrillwiki.com/images/${result.id}`
|
|
|
|
addSpanEvent(context.span, 'image_status_retrieved', { imageId: result.id });
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
id: result.id,
|
|
uploaded: result.uploaded,
|
|
variants: result.variants,
|
|
draft: result.draft,
|
|
urls: result.uploaded ? {
|
|
public: `${baseUrl}/public`,
|
|
thumbnail: `${baseUrl}/thumbnail`,
|
|
medium: `${baseUrl}/medium`,
|
|
large: `${baseUrl}/large`,
|
|
avatar: `${baseUrl}/avatar`,
|
|
} : null
|
|
}),
|
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Method not allowed',
|
|
message: 'HTTP method not supported for this endpoint'
|
|
}),
|
|
{
|
|
status: 405,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
);
|
|
|
|
export default withRateLimit(handler, rateLimiters.strict, {} as any);
|