|
@@ -0,0 +1,353 @@
|
|
|
|
|
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
|
|
|
|
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|
|
|
|
+import { createHmac } from 'https://deno.land/std@0.168.0/node/crypto.ts'
|
|
|
|
|
+
|
|
|
|
|
+const corsHeaders = {
|
|
|
|
|
+ 'Access-Control-Allow-Origin': '*',
|
|
|
|
|
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Generate OAuth 1.0a signature
|
|
|
|
|
+function generateOAuthSignature(
|
|
|
|
|
+ method: string,
|
|
|
|
|
+ url: string,
|
|
|
|
|
+ params: Record<string, string>,
|
|
|
|
|
+ consumerSecret: string = '',
|
|
|
|
|
+ tokenSecret: string = ''
|
|
|
|
|
+): string {
|
|
|
|
|
+ // Sort parameters
|
|
|
|
|
+ const sortedParams = Object.keys(params)
|
|
|
|
|
+ .sort()
|
|
|
|
|
+ .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
|
|
|
|
+ .join('&')
|
|
|
|
|
+
|
|
|
|
|
+ // Create signature base string
|
|
|
|
|
+ const signatureBaseString = [
|
|
|
|
|
+ method.toUpperCase(),
|
|
|
|
|
+ encodeURIComponent(url),
|
|
|
|
|
+ encodeURIComponent(sortedParams)
|
|
|
|
|
+ ].join('&')
|
|
|
|
|
+
|
|
|
|
|
+ // Create signing key
|
|
|
|
|
+ const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`
|
|
|
|
|
+
|
|
|
|
|
+ // Generate signature
|
|
|
|
|
+ const signature = createHmac('sha256', signingKey)
|
|
|
|
|
+ .update(signatureBaseString)
|
|
|
|
|
+ .digest('base64')
|
|
|
|
|
+
|
|
|
|
|
+ return signature
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Validate store URL
|
|
|
|
|
+function validateStoreUrl(storeUrl: string): { valid: boolean; error?: string; normalized?: string } {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Remove trailing slash
|
|
|
|
|
+ let normalized = storeUrl.trim().replace(/\/$/, '')
|
|
|
|
|
+
|
|
|
|
|
+ // Add https:// if missing
|
|
|
|
|
+ if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
|
|
|
|
|
+ normalized = `https://${normalized}`
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Parse URL
|
|
|
|
|
+ const url = new URL(normalized)
|
|
|
|
|
+
|
|
|
|
|
+ // Must be HTTPS for security
|
|
|
|
|
+ if (url.protocol !== 'https:') {
|
|
|
|
|
+ return { valid: false, error: 'Store URL must use HTTPS' }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { valid: true, normalized }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ return { valid: false, error: 'Invalid store URL format' }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Test WooCommerce API connection
|
|
|
|
|
+async function testWooCommerceConnection(
|
|
|
|
|
+ storeUrl: string,
|
|
|
|
|
+ consumerKey: string,
|
|
|
|
|
+ consumerSecret: string
|
|
|
|
|
+): Promise<{ success: boolean; error?: string; data?: any }> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Test with system status endpoint
|
|
|
|
|
+ const testUrl = `${storeUrl}/wp-json/wc/v3/system_status`
|
|
|
|
|
+
|
|
|
|
|
+ const oauthParams: Record<string, string> = {
|
|
|
|
|
+ oauth_consumer_key: consumerKey,
|
|
|
|
|
+ oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
|
|
|
|
|
+ oauth_nonce: crypto.randomUUID().replace(/-/g, ''),
|
|
|
|
|
+ oauth_signature_method: 'HMAC-SHA256',
|
|
|
|
|
+ oauth_version: '1.0'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Generate signature
|
|
|
|
|
+ const signature = generateOAuthSignature('GET', testUrl, oauthParams, consumerSecret)
|
|
|
|
|
+ oauthParams.oauth_signature = signature
|
|
|
|
|
+
|
|
|
|
|
+ // Build authorization header
|
|
|
|
|
+ const authHeader = 'OAuth ' + Object.keys(oauthParams)
|
|
|
|
|
+ .map(key => `${key}="${encodeURIComponent(oauthParams[key])}"`)
|
|
|
|
|
+ .join(', ')
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch(testUrl, {
|
|
|
|
|
+ method: 'GET',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Authorization': authHeader,
|
|
|
|
|
+ 'Accept': 'application/json'
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ const errorText = await response.text()
|
|
|
|
|
+ console.error('[WooCommerce] API test failed:', response.status, errorText)
|
|
|
|
|
+ return {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ error: `API connection failed (${response.status}): ${errorText.substring(0, 100)}`
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json()
|
|
|
|
|
+ return { success: true, data }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('[WooCommerce] Connection test error:', error)
|
|
|
|
|
+ return {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ error: error instanceof Error ? error.message : 'Failed to connect to WooCommerce store'
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+serve(async (req) => {
|
|
|
|
|
+ if (req.method === 'OPTIONS') {
|
|
|
|
|
+ return new Response('ok', { headers: corsHeaders })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const url = new URL(req.url)
|
|
|
|
|
+ const action = url.searchParams.get('action') || 'init'
|
|
|
|
|
+
|
|
|
|
|
+ // Get environment variables
|
|
|
|
|
+ const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
|
|
|
|
+ const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY')!
|
|
|
|
|
+ const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
|
|
|
+ const frontendUrl = Deno.env.get('FRONTEND_URL') || 'https://shopcall.ai'
|
|
|
|
|
+
|
|
|
|
|
+ // Handle OAuth initiation
|
|
|
|
|
+ if (action === 'init') {
|
|
|
|
|
+ const storeUrl = url.searchParams.get('store_url')
|
|
|
|
|
+
|
|
|
|
|
+ if (!storeUrl) {
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({ error: 'store_url parameter is required' }),
|
|
|
|
|
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Validate store URL
|
|
|
|
|
+ const validation = validateStoreUrl(storeUrl)
|
|
|
|
|
+ if (!validation.valid) {
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({ error: validation.error }),
|
|
|
|
|
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Get user from authorization header
|
|
|
|
|
+ const authHeader = req.headers.get('authorization')
|
|
|
|
|
+ if (!authHeader) {
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({ error: 'No authorization header' }),
|
|
|
|
|
+ { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const token = authHeader.replace('Bearer ', '')
|
|
|
|
|
+ const supabase = createClient(supabaseUrl, supabaseKey)
|
|
|
|
|
+ const { data: { user }, error: userError } = await supabase.auth.getUser(token)
|
|
|
|
|
+
|
|
|
|
|
+ if (userError || !user) {
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({ error: 'Invalid token' }),
|
|
|
|
|
+ { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Generate state for CSRF protection
|
|
|
|
|
+ const state = crypto.randomUUID()
|
|
|
|
|
+
|
|
|
|
|
+ // Store state in database
|
|
|
|
|
+ const { error: stateError } = await supabase
|
|
|
|
|
+ .from('oauth_states')
|
|
|
|
|
+ .insert({
|
|
|
|
|
+ state,
|
|
|
|
|
+ user_id: user.id,
|
|
|
|
|
+ platform: 'woocommerce',
|
|
|
|
|
+ shopname: validation.normalized,
|
|
|
|
|
+ expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (stateError) {
|
|
|
|
|
+ console.error('[WooCommerce] Error storing state:', stateError)
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({ error: 'Failed to initiate OAuth flow' }),
|
|
|
|
|
+ { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Build WooCommerce OAuth authorization URL
|
|
|
|
|
+ const callbackUrl = `${url.protocol}//${url.host}${url.pathname}?action=callback`
|
|
|
|
|
+ const appName = 'ShopCall.ai'
|
|
|
|
|
+ const returnUrl = `${frontendUrl}/webshops?wc_connected=true`
|
|
|
|
|
+
|
|
|
|
|
+ const authUrl = new URL(`${validation.normalized}/wc-auth/v1/authorize`)
|
|
|
|
|
+ authUrl.searchParams.set('app_name', appName)
|
|
|
|
|
+ authUrl.searchParams.set('scope', 'read')
|
|
|
|
|
+ authUrl.searchParams.set('user_id', user.id)
|
|
|
|
|
+ authUrl.searchParams.set('return_url', returnUrl)
|
|
|
|
|
+ authUrl.searchParams.set('callback_url', callbackUrl)
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`[WooCommerce] OAuth initiated for ${validation.normalized}`)
|
|
|
|
|
+
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ authUrl: authUrl.toString(),
|
|
|
|
|
+ message: 'Redirect to WooCommerce for authorization'
|
|
|
|
|
+ }),
|
|
|
|
|
+ { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Handle OAuth callback
|
|
|
|
|
+ if (action === 'callback') {
|
|
|
|
|
+ const success = url.searchParams.get('success')
|
|
|
|
|
+ const userId = url.searchParams.get('user_id')
|
|
|
|
|
+ const consumerKey = url.searchParams.get('consumer_key')
|
|
|
|
|
+ const consumerSecret = url.searchParams.get('consumer_secret')
|
|
|
|
|
+ const storeUrlParam = url.searchParams.get('store_url')
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`[WooCommerce] OAuth callback received - success: ${success}`)
|
|
|
|
|
+
|
|
|
|
|
+ if (success !== '1') {
|
|
|
|
|
+ console.error('[WooCommerce] OAuth rejected by user')
|
|
|
|
|
+ return new Response(null, {
|
|
|
|
|
+ status: 302,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ ...corsHeaders,
|
|
|
|
|
+ 'Location': `${frontendUrl}/webshops?error=woocommerce_oauth_rejected`
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!userId || !consumerKey || !consumerSecret || !storeUrlParam) {
|
|
|
|
|
+ console.error('[WooCommerce] Missing required callback parameters')
|
|
|
|
|
+ return new Response(null, {
|
|
|
|
|
+ status: 302,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ ...corsHeaders,
|
|
|
|
|
+ 'Location': `${frontendUrl}/webshops?error=woocommerce_oauth_failed`
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Validate store URL
|
|
|
|
|
+ const validation = validateStoreUrl(storeUrlParam)
|
|
|
|
|
+ if (!validation.valid) {
|
|
|
|
|
+ return new Response(null, {
|
|
|
|
|
+ status: 302,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ ...corsHeaders,
|
|
|
|
|
+ 'Location': `${frontendUrl}/webshops?error=invalid_store_url`
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Test API connection
|
|
|
|
|
+ const testResult = await testWooCommerceConnection(
|
|
|
|
|
+ validation.normalized!,
|
|
|
|
|
+ consumerKey,
|
|
|
|
|
+ consumerSecret
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if (!testResult.success) {
|
|
|
|
|
+ console.error('[WooCommerce] API connection test failed:', testResult.error)
|
|
|
|
|
+ return new Response(null, {
|
|
|
|
|
+ status: 302,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ ...corsHeaders,
|
|
|
|
|
+ 'Location': `${frontendUrl}/webshops?error=woocommerce_connection_failed`
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Extract store info from API response
|
|
|
|
|
+ const systemStatus = testResult.data
|
|
|
|
|
+ const wcVersion = systemStatus?.environment?.version || 'unknown'
|
|
|
|
|
+ const wpVersion = systemStatus?.environment?.wp_version || 'unknown'
|
|
|
|
|
+ const storeName = systemStatus?.settings?.site_title?.value || new URL(validation.normalized!).hostname
|
|
|
|
|
+
|
|
|
|
|
+ // Create Supabase admin client
|
|
|
|
|
+ const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey)
|
|
|
|
|
+
|
|
|
|
|
+ // Store credentials in database
|
|
|
|
|
+ const { error: insertError } = await supabaseAdmin
|
|
|
|
|
+ .from('stores')
|
|
|
|
|
+ .insert({
|
|
|
|
|
+ user_id: userId,
|
|
|
|
|
+ platform_name: 'woocommerce',
|
|
|
|
|
+ store_name: storeName,
|
|
|
|
|
+ store_url: validation.normalized,
|
|
|
|
|
+ api_key: consumerKey,
|
|
|
|
|
+ api_secret: consumerSecret,
|
|
|
|
|
+ scopes: ['read'],
|
|
|
|
|
+ alt_data: {
|
|
|
|
|
+ wcVersion,
|
|
|
|
|
+ wpVersion,
|
|
|
|
|
+ apiVersion: 'wc/v3',
|
|
|
|
|
+ connectedAt: new Date().toISOString()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (insertError) {
|
|
|
|
|
+ console.error('[WooCommerce] Error storing credentials:', insertError)
|
|
|
|
|
+ return new Response(null, {
|
|
|
|
|
+ status: 302,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ ...corsHeaders,
|
|
|
|
|
+ 'Location': `${frontendUrl}/webshops?error=failed_to_save`
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`[WooCommerce] Store connected successfully: ${storeName}`)
|
|
|
|
|
+
|
|
|
|
|
+ // Redirect back to frontend with success
|
|
|
|
|
+ return new Response(null, {
|
|
|
|
|
+ status: 302,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ ...corsHeaders,
|
|
|
|
|
+ 'Location': `${frontendUrl}/webshops?wc_connected=true&store=${encodeURIComponent(storeName)}`
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Unknown action
|
|
|
|
|
+ return new Response(
|
|
|
|
|
+ JSON.stringify({ error: 'Invalid action parameter' }),
|
|
|
|
|
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('[WooCommerce] Error:', error)
|
|
|
|
|
+ const frontendUrl = Deno.env.get('FRONTEND_URL') || 'https://shopcall.ai'
|
|
|
|
|
+ return new Response(null, {
|
|
|
|
|
+ status: 302,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ ...corsHeaders,
|
|
|
|
|
+ 'Location': `${frontendUrl}/webshops?error=internal_error`
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+})
|