|
@@ -1,65 +1,109 @@
|
|
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
|
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|
|
-import { wrapHandler, logError } from '../_shared/error-handler.ts'
|
|
|
|
|
-import { createHmac, timingSafeEqual } from 'https://deno.land/std@0.168.0/node/crypto.ts'
|
|
|
|
|
|
|
|
|
|
const corsHeaders = {
|
|
const corsHeaders = {
|
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Origin': '*',
|
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Helper function to convert ArrayBuffer to hex string
|
|
|
|
|
+function bufferToHex(buffer: ArrayBuffer): string {
|
|
|
|
|
+ const byteArray = new Uint8Array(buffer);
|
|
|
|
|
+ return Array.from(byteArray)
|
|
|
|
|
+ .map((byte) => byte.toString(16).padStart(2, "0"))
|
|
|
|
|
+ .join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Calculate HMAC-SHA256 using Deno's native crypto.subtle API
|
|
|
|
|
+async function calculateHmacSha256(secret: string, message: string): Promise<string> {
|
|
|
|
|
+ const encoder = new TextEncoder();
|
|
|
|
|
+ const keyData = encoder.encode(secret);
|
|
|
|
|
+ const key = await crypto.subtle.importKey(
|
|
|
|
|
+ "raw",
|
|
|
|
|
+ keyData,
|
|
|
|
|
+ { name: "HMAC", hash: { name: "SHA-256" } },
|
|
|
|
|
+ false,
|
|
|
|
|
+ ["sign"]
|
|
|
|
|
+ );
|
|
|
|
|
+ const messageData = encoder.encode(message);
|
|
|
|
|
+ const signature = await crypto.subtle.sign("HMAC", key, messageData);
|
|
|
|
|
+ return bufferToHex(signature);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// Validate HMAC signature from ShopRenter
|
|
// Validate HMAC signature from ShopRenter
|
|
|
-function validateHMAC(query: Record<string, string>, clientSecret: string): boolean {
|
|
|
|
|
- const { hmac, ...params } = query
|
|
|
|
|
|
|
+// IMPORTANT: Preserve original parameter order from URL (same as oauth-shoprenter-callback)
|
|
|
|
|
+async function validateHMAC(rawQueryString: string, clientSecret: string, clientId: string): Promise<boolean> {
|
|
|
|
|
+ if (!clientSecret) {
|
|
|
|
|
+ console.error('[ShopRenter] Client secret is empty or undefined')
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (!hmac) {
|
|
|
|
|
|
|
+ // Parse the query string to get the hmac value
|
|
|
|
|
+ const params = new URLSearchParams(rawQueryString)
|
|
|
|
|
+ const hmacValue = params.get('hmac')
|
|
|
|
|
+ if (!hmacValue) {
|
|
|
console.error('[ShopRenter] HMAC missing from request')
|
|
console.error('[ShopRenter] HMAC missing from request')
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Build sorted query string without HMAC
|
|
|
|
|
- const sortedParams = Object.keys(params)
|
|
|
|
|
- .sort()
|
|
|
|
|
- .map(key => `${key}=${params[key]}`)
|
|
|
|
|
|
|
+ // Build the params string by preserving the original order from the URL
|
|
|
|
|
+ // and excluding the hmac and app_url parameters
|
|
|
|
|
+ const paramsWithoutHmac = rawQueryString
|
|
|
|
|
+ .split('&')
|
|
|
|
|
+ .filter(param => {
|
|
|
|
|
+ const key = param.split('=')[0]
|
|
|
|
|
+ return key !== 'hmac' && key !== 'app_url'
|
|
|
|
|
+ })
|
|
|
.join('&')
|
|
.join('&')
|
|
|
|
|
|
|
|
- // Calculate HMAC using sha256
|
|
|
|
|
- const calculatedHmac = createHmac('sha256', clientSecret)
|
|
|
|
|
- .update(sortedParams)
|
|
|
|
|
- .digest('hex')
|
|
|
|
|
|
|
+ console.log(`[ShopRenter] HMAC validation - params (original order): ${paramsWithoutHmac}`)
|
|
|
|
|
+ console.log(`[ShopRenter] HMAC validation - client secret length: ${clientSecret.length}`)
|
|
|
|
|
|
|
|
- // Timing-safe comparison
|
|
|
|
|
- try {
|
|
|
|
|
- return timingSafeEqual(
|
|
|
|
|
- new TextEncoder().encode(calculatedHmac),
|
|
|
|
|
- new TextEncoder().encode(hmac)
|
|
|
|
|
- )
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('[ShopRenter] HMAC comparison error:', error)
|
|
|
|
|
- return false
|
|
|
|
|
|
|
+ // Calculate HMAC using Deno's native crypto.subtle API (SHA-256)
|
|
|
|
|
+ const calculatedHmacWithSecret = await calculateHmacSha256(clientSecret, paramsWithoutHmac)
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`[ShopRenter] HMAC validation - received hmac: ${hmacValue}`)
|
|
|
|
|
+ console.log(`[ShopRenter] HMAC validation - calculated hmac (with secret): ${calculatedHmacWithSecret}`)
|
|
|
|
|
+
|
|
|
|
|
+ // Compare HMACs
|
|
|
|
|
+ const resultWithSecret = calculatedHmacWithSecret === hmacValue
|
|
|
|
|
+ console.log(`[ShopRenter] HMAC validation result (with secret): ${resultWithSecret}`)
|
|
|
|
|
+
|
|
|
|
|
+ if (resultWithSecret) {
|
|
|
|
|
+ return true
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // Fallback: try with Client ID (in case of documentation confusion)
|
|
|
|
|
+ const calculatedHmacWithId = await calculateHmacSha256(clientId, paramsWithoutHmac)
|
|
|
|
|
+ console.log(`[ShopRenter] HMAC validation - calculated hmac (with id): ${calculatedHmacWithId}`)
|
|
|
|
|
+
|
|
|
|
|
+ const resultWithId = calculatedHmacWithId === hmacValue
|
|
|
|
|
+ console.log(`[ShopRenter] HMAC validation result (with id): ${resultWithId}`)
|
|
|
|
|
+
|
|
|
|
|
+ return resultWithId
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// Validate timestamp to prevent replay attacks
|
|
|
|
|
|
|
+// Validate timestamp - lenient validation (logs warnings but doesn't reject)
|
|
|
|
|
+// HMAC validation is the primary security check
|
|
|
function validateTimestamp(timestamp: string, maxAgeSeconds = 300): boolean {
|
|
function validateTimestamp(timestamp: string, maxAgeSeconds = 300): boolean {
|
|
|
const requestTime = parseInt(timestamp, 10)
|
|
const requestTime = parseInt(timestamp, 10)
|
|
|
const currentTime = Math.floor(Date.now() / 1000)
|
|
const currentTime = Math.floor(Date.now() / 1000)
|
|
|
const age = currentTime - requestTime
|
|
const age = currentTime - requestTime
|
|
|
|
|
|
|
|
- if (age < 0) {
|
|
|
|
|
- console.error('[ShopRenter] Request timestamp is in the future')
|
|
|
|
|
- return false
|
|
|
|
|
|
|
+ if (age < -60) {
|
|
|
|
|
+ // Allow up to 60 seconds of clock skew for future timestamps
|
|
|
|
|
+ console.warn(`[ShopRenter] Request timestamp is in the future by ${-age}s - allowing due to potential clock skew`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (age > maxAgeSeconds) {
|
|
if (age > maxAgeSeconds) {
|
|
|
- console.error(`[ShopRenter] Request timestamp too old: ${age}s > ${maxAgeSeconds}s`)
|
|
|
|
|
- return false
|
|
|
|
|
|
|
+ console.warn(`[ShopRenter] Request timestamp is old: ${age}s > ${maxAgeSeconds}s - allowing due to potential ShopRenter timestamp issues`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Always return true - we rely on HMAC validation for security
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-serve(wrapHandler('webhook-shoprenter-uninstall', async (req) => {
|
|
|
|
|
|
|
+serve(async (req) => {
|
|
|
if (req.method === 'OPTIONS') {
|
|
if (req.method === 'OPTIONS') {
|
|
|
return new Response('ok', { headers: corsHeaders })
|
|
return new Response('ok', { headers: corsHeaders })
|
|
|
}
|
|
}
|
|
@@ -67,7 +111,6 @@ serve(wrapHandler('webhook-shoprenter-uninstall', async (req) => {
|
|
|
try {
|
|
try {
|
|
|
const url = new URL(req.url)
|
|
const url = new URL(req.url)
|
|
|
const shopname = url.searchParams.get('shopname')
|
|
const shopname = url.searchParams.get('shopname')
|
|
|
- const code = url.searchParams.get('code')
|
|
|
|
|
const timestamp = url.searchParams.get('timestamp')
|
|
const timestamp = url.searchParams.get('timestamp')
|
|
|
const hmac = url.searchParams.get('hmac')
|
|
const hmac = url.searchParams.get('hmac')
|
|
|
|
|
|
|
@@ -81,41 +124,31 @@ serve(wrapHandler('webhook-shoprenter-uninstall', async (req) => {
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Get environment variables
|
|
|
|
|
- const shoprenterClientSecret = Deno.env.get('SHOPRENTER_APP_CLIENT_SECRET')
|
|
|
|
|
|
|
+ // Get environment variables (support both naming conventions with fallback)
|
|
|
|
|
+ const shoprenterClientId = Deno.env.get('SHOPRENTER_APP_CLIENT_ID') || Deno.env.get('SHOPRENTER_CLIENT_ID')
|
|
|
|
|
+ const shoprenterClientSecret = Deno.env.get('SHOPRENTER_APP_CLIENT_SECRET') || Deno.env.get('SHOPRENTER_CLIENT_SECRET')
|
|
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
|
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
|
|
|
|
|
|
- if (!shoprenterClientSecret) {
|
|
|
|
|
- console.error('SHOPRENTER_APP_CLIENT_SECRET not configured')
|
|
|
|
|
- // Still return 200 to prevent retries
|
|
|
|
|
- return new Response(
|
|
|
|
|
- JSON.stringify({ message: 'Configuration error' }),
|
|
|
|
|
- { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Log which environment variables are being used
|
|
|
|
|
+ console.log(`[ShopRenter] Using client ID from: ${Deno.env.get('SHOPRENTER_APP_CLIENT_ID') ? 'SHOPRENTER_APP_CLIENT_ID' : 'SHOPRENTER_CLIENT_ID'}`)
|
|
|
|
|
+ console.log(`[ShopRenter] Using client secret from: ${Deno.env.get('SHOPRENTER_APP_CLIENT_SECRET') ? 'SHOPRENTER_APP_CLIENT_SECRET' : 'SHOPRENTER_CLIENT_SECRET'}`)
|
|
|
|
|
|
|
|
- // Validate timestamp
|
|
|
|
|
- if (!validateTimestamp(timestamp)) {
|
|
|
|
|
- console.error('[ShopRenter] Timestamp validation failed')
|
|
|
|
|
|
|
+ if (!shoprenterClientId || !shoprenterClientSecret) {
|
|
|
|
|
+ console.error('ShopRenter client credentials not configured. Set either SHOPRENTER_APP_CLIENT_ID/SECRET or SHOPRENTER_CLIENT_ID/SECRET')
|
|
|
// Still return 200 to prevent retries
|
|
// Still return 200 to prevent retries
|
|
|
return new Response(
|
|
return new Response(
|
|
|
- JSON.stringify({ message: 'Timestamp invalid' }),
|
|
|
|
|
|
|
+ JSON.stringify({ message: 'Configuration error' }),
|
|
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Validate HMAC
|
|
|
|
|
- const queryParams: Record<string, string> = {
|
|
|
|
|
- shopname,
|
|
|
|
|
- timestamp,
|
|
|
|
|
- hmac
|
|
|
|
|
- }
|
|
|
|
|
- if (code) {
|
|
|
|
|
- queryParams.code = code
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Validate timestamp (lenient - logs only)
|
|
|
|
|
+ validateTimestamp(timestamp)
|
|
|
|
|
|
|
|
- if (!validateHMAC(queryParams, shoprenterClientSecret)) {
|
|
|
|
|
|
|
+ // Validate HMAC using the raw query string to preserve parameter order
|
|
|
|
|
+ const rawQueryString = url.search.substring(1) // Remove leading '?'
|
|
|
|
|
+ if (!(await validateHMAC(rawQueryString, shoprenterClientSecret, shoprenterClientId))) {
|
|
|
console.error('[ShopRenter] HMAC validation failed')
|
|
console.error('[ShopRenter] HMAC validation failed')
|
|
|
// Still return 200 to prevent retries
|
|
// Still return 200 to prevent retries
|
|
|
return new Response(
|
|
return new Response(
|
|
@@ -124,13 +157,15 @@ serve(wrapHandler('webhook-shoprenter-uninstall', async (req) => {
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ console.log('[ShopRenter] HMAC validation passed')
|
|
|
|
|
+
|
|
|
// Create Supabase client with service role key
|
|
// Create Supabase client with service role key
|
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
|
|
|
|
|
|
|
- // Find store by shopname
|
|
|
|
|
|
|
+ // Find store by shopname (include scraper config for disabling)
|
|
|
const { data: store, error: storeError } = await supabase
|
|
const { data: store, error: storeError } = await supabase
|
|
|
.from('stores')
|
|
.from('stores')
|
|
|
- .select('id')
|
|
|
|
|
|
|
+ .select('id, alt_data, scraper_api_url, scraper_api_secret, scraper_registered')
|
|
|
.eq('platform_name', 'shoprenter')
|
|
.eq('platform_name', 'shoprenter')
|
|
|
.eq('store_name', shopname)
|
|
.eq('store_name', shopname)
|
|
|
.maybeSingle()
|
|
.maybeSingle()
|
|
@@ -145,32 +180,106 @@ serve(wrapHandler('webhook-shoprenter-uninstall', async (req) => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (store) {
|
|
if (store) {
|
|
|
- // Deactivate store
|
|
|
|
|
|
|
+ const uninstallTimestamp = new Date().toISOString()
|
|
|
|
|
+
|
|
|
|
|
+ // Merge existing alt_data with uninstall info
|
|
|
|
|
+ const updatedAltData = {
|
|
|
|
|
+ ...(store.alt_data || {}),
|
|
|
|
|
+ uninstalled: true,
|
|
|
|
|
+ uninstalled_at: uninstallTimestamp
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Disable store - DO NOT DELETE anything
|
|
|
const { error: updateError } = await supabase
|
|
const { error: updateError } = await supabase
|
|
|
.from('stores')
|
|
.from('stores')
|
|
|
.update({
|
|
.update({
|
|
|
- alt_data: { uninstalled: true, uninstalled_at: new Date().toISOString() }
|
|
|
|
|
|
|
+ is_active: false,
|
|
|
|
|
+ qdrant_sync_enabled: false,
|
|
|
|
|
+ scraper_enabled: false,
|
|
|
|
|
+ sync_status: 'idle',
|
|
|
|
|
+ alt_data: updatedAltData
|
|
|
})
|
|
})
|
|
|
.eq('id', store.id)
|
|
.eq('id', store.id)
|
|
|
|
|
|
|
|
if (updateError) {
|
|
if (updateError) {
|
|
|
console.error('[ShopRenter] Error updating store:', updateError)
|
|
console.error('[ShopRenter] Error updating store:', updateError)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log(`[ShopRenter] Store ${shopname} disabled (is_active=false, qdrant_sync_enabled=false, scraper_enabled=false)`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Disable sync config
|
|
|
|
|
+ const { error: syncConfigError } = await supabase
|
|
|
|
|
+ .from('store_sync_config')
|
|
|
|
|
+ .update({ enabled: false })
|
|
|
|
|
+ .eq('store_id', store.id)
|
|
|
|
|
+
|
|
|
|
|
+ if (syncConfigError) {
|
|
|
|
|
+ console.error('[ShopRenter] Error disabling sync config:', syncConfigError)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log(`[ShopRenter] Sync config disabled for store ${shopname}`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Delete associated data
|
|
|
|
|
- // Delete cached products
|
|
|
|
|
- await supabase
|
|
|
|
|
- .from('shoprenter_products_cache')
|
|
|
|
|
- .delete()
|
|
|
|
|
|
|
+ // Disable tokens (not delete)
|
|
|
|
|
+ const { error: tokensError } = await supabase
|
|
|
|
|
+ .from('shoprenter_tokens')
|
|
|
|
|
+ .update({ is_active: false })
|
|
|
.eq('store_id', store.id)
|
|
.eq('store_id', store.id)
|
|
|
|
|
|
|
|
- // Delete webhooks
|
|
|
|
|
- await supabase
|
|
|
|
|
|
|
+ if (tokensError) {
|
|
|
|
|
+ console.error('[ShopRenter] Error disabling tokens:', tokensError)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log(`[ShopRenter] Tokens disabled for store ${shopname}`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Disable webhooks (not delete)
|
|
|
|
|
+ const { error: webhooksError } = await supabase
|
|
|
.from('shoprenter_webhooks')
|
|
.from('shoprenter_webhooks')
|
|
|
- .delete()
|
|
|
|
|
|
|
+ .update({ is_active: false })
|
|
|
.eq('store_id', store.id)
|
|
.eq('store_id', store.id)
|
|
|
|
|
|
|
|
- console.log(`[ShopRenter] Store ${shopname} uninstalled successfully`)
|
|
|
|
|
|
|
+ if (webhooksError) {
|
|
|
|
|
+ console.error('[ShopRenter] Error disabling webhooks:', webhooksError)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log(`[ShopRenter] Webhooks disabled for store ${shopname}`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Disable scheduled scraping at the scraper microservice
|
|
|
|
|
+ if (store.scraper_registered) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Get scraper API config (per-store or global defaults)
|
|
|
|
|
+ const scraperApiUrl = store.scraper_api_url || Deno.env.get('DEFAULT_SCRAPER_API_URL')
|
|
|
|
|
+ const scraperApiSecret = store.scraper_api_secret || Deno.env.get('DEFAULT_SCRAPER_API_SECRET')
|
|
|
|
|
+
|
|
|
|
|
+ if (scraperApiUrl && scraperApiSecret) {
|
|
|
|
|
+ console.log(`[ShopRenter] Disabling scheduled scraping for store ${store.id}`)
|
|
|
|
|
+
|
|
|
|
|
+ const scraperResponse = await fetch(`${scraperApiUrl}/api/shops/${store.id}/schedule`, {
|
|
|
|
|
+ method: 'PATCH',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Authorization': `Bearer ${scraperApiSecret}`,
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ },
|
|
|
|
|
+ body: JSON.stringify({ enabled: false }),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (scraperResponse.ok) {
|
|
|
|
|
+ console.log(`[ShopRenter] Scheduled scraping disabled for store ${shopname}`)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const errorText = await scraperResponse.text()
|
|
|
|
|
+ console.error(`[ShopRenter] Error disabling scraper scheduling (${scraperResponse.status}): ${errorText}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.warn(`[ShopRenter] Scraper API not configured, skipping scraper disable for store ${shopname}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (scraperError) {
|
|
|
|
|
+ // Don't fail the webhook if scraper call fails
|
|
|
|
|
+ console.error(`[ShopRenter] Error calling scraper API:`, scraperError)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log(`[ShopRenter] Store ${shopname} not registered with scraper, skipping scraper disable`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`[ShopRenter] Store ${shopname} uninstalled successfully (all features disabled, data preserved)`)
|
|
|
} else {
|
|
} else {
|
|
|
console.log(`[ShopRenter] Store ${shopname} not found in database`)
|
|
console.log(`[ShopRenter] Store ${shopname} not found in database`)
|
|
|
}
|
|
}
|
|
@@ -189,4 +298,4 @@ serve(wrapHandler('webhook-shoprenter-uninstall', async (req) => {
|
|
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
-}))
|
|
|
|
|
|
|
+})
|