Browse Source

fix: resolve CORS errors and secure scraper-management API

- Fix CORS errors by removing duplicate /functions/v1 from API URLs
- Secure scraper-management Edge Function with proper JWT authentication
- Replace service role key with anon key for Row-Level Security enforcement
- Simplify Vite chunk configuration to prevent circular dependencies
- Ensure all API requests include proper Authorization headers
- Update scraper-management to validate user access to stores

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh 4 months ago
parent
commit
6ebca95bfe

+ 5 - 5
shopcall.ai-main/src/components/ManageStoreDataContent.tsx

@@ -348,7 +348,7 @@ export function ManageStoreDataContent() {
       if (!sessionData) throw new Error('No session data found');
       if (!sessionData) throw new Error('No session data found');
 
 
       const session = JSON.parse(sessionData);
       const session = JSON.parse(sessionData);
-      const response = await fetch(`${API_URL}/functions/v1/scraper-management/shop-status?store_id=${selectedStore.id}`, {
+      const response = await fetch(`${API_URL}/scraper-management/shop-status?store_id=${selectedStore.id}`, {
         headers: {
         headers: {
           'Authorization': `Bearer ${session.session.access_token}`,
           'Authorization': `Bearer ${session.session.access_token}`,
           'Content-Type': 'application/json'
           'Content-Type': 'application/json'
@@ -384,7 +384,7 @@ export function ManageStoreDataContent() {
         params.append('search', searchQuery);
         params.append('search', searchQuery);
       }
       }
 
 
-      const response = await fetch(`${API_URL}/functions/v1/scraper-management/shop-content?${params.toString()}`, {
+      const response = await fetch(`${API_URL}/scraper-management/shop-content?${params.toString()}`, {
         headers: {
         headers: {
           'Authorization': `Bearer ${session.session.access_token}`,
           'Authorization': `Bearer ${session.session.access_token}`,
           'Content-Type': 'application/json'
           'Content-Type': 'application/json'
@@ -408,7 +408,7 @@ export function ManageStoreDataContent() {
       if (!sessionData) throw new Error('No session data found');
       if (!sessionData) throw new Error('No session data found');
 
 
       const session = JSON.parse(sessionData);
       const session = JSON.parse(sessionData);
-      const response = await fetch(`${API_URL}/functions/v1/scraper-management/custom-urls?store_id=${selectedStore.id}`, {
+      const response = await fetch(`${API_URL}/scraper-management/custom-urls?store_id=${selectedStore.id}`, {
         headers: {
         headers: {
           'Authorization': `Bearer ${session.session.access_token}`,
           'Authorization': `Bearer ${session.session.access_token}`,
           'Content-Type': 'application/json'
           'Content-Type': 'application/json'
@@ -432,7 +432,7 @@ export function ManageStoreDataContent() {
       if (!sessionData) throw new Error('No session data found');
       if (!sessionData) throw new Error('No session data found');
 
 
       const session = JSON.parse(sessionData);
       const session = JSON.parse(sessionData);
-      const response = await fetch(`${API_URL}/functions/v1/scraper-management/custom-urls`, {
+      const response = await fetch(`${API_URL}/scraper-management/custom-urls`, {
         method: 'POST',
         method: 'POST',
         headers: {
         headers: {
           'Authorization': `Bearer ${session.session.access_token}`,
           'Authorization': `Bearer ${session.session.access_token}`,
@@ -473,7 +473,7 @@ export function ManageStoreDataContent() {
       if (!sessionData) throw new Error('No session data found');
       if (!sessionData) throw new Error('No session data found');
 
 
       const session = JSON.parse(sessionData);
       const session = JSON.parse(sessionData);
-      const response = await fetch(`${API_URL}/functions/v1/scraper-management/scheduling`, {
+      const response = await fetch(`${API_URL}/scraper-management/scheduling`, {
         method: 'PATCH',
         method: 'PATCH',
         headers: {
         headers: {
           'Authorization': `Bearer ${session.session.access_token}`,
           'Authorization': `Bearer ${session.session.access_token}`,

+ 561 - 0
shopcall.ai-main/supabase/functions/scraper-management/index.ts

@@ -0,0 +1,561 @@
+/**
+ * Scraper Management API
+ *
+ * Provides endpoints for managing webshop scraping functionality:
+ * - Register/unregister shops with scraper
+ * - Get shop status and scraped content
+ * - Manage custom URLs
+ * - Configure webhooks and scheduling
+ * - Multi-scraper support with per-store configuration
+ *
+ * SECURITY: This function requires JWT authentication (verify_jwt: true)
+ * The JWT verification is handled automatically by Supabase Edge Functions
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
+import { createScraperClient, validateSameDomain } from '../_shared/scraper-client.ts';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+  'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS'
+};
+
+/**
+ * Get scraper configuration for a store
+ */
+async function getStoreScraperConfig(supabase: any, storeId: string, userId: string) {
+  const { data: store, error } = await supabase
+    .from('stores')
+    .select('id, store_url, scraper_api_url, scraper_api_secret, scraper_registered, scraper_enabled, user_id')
+    .eq('id', storeId)
+    .eq('user_id', userId)
+    .single();
+
+  if (error || !store) {
+    throw new Error('Store not found or access denied');
+  }
+
+  return store;
+}
+
+/**
+ * Update store scraper registration status
+ */
+async function updateStoreScraperStatus(supabase: any, storeId: string, updates: any) {
+  const { error } = await supabase
+    .from('stores')
+    .update(updates)
+    .eq('id', storeId);
+
+  if (error) {
+    throw new Error(`Failed to update store status: ${error.message}`);
+  }
+}
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight requests
+  if (req.method === 'OPTIONS') {
+    return new Response(null, {
+      headers: corsHeaders
+    });
+  }
+
+  try {
+    // Initialize Supabase client with ANON key for proper RLS
+    const supabaseUrl = Deno.env.get('SUPABASE_URL');
+    const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY');
+
+    if (!supabaseUrl || !supabaseAnonKey) {
+      throw new Error('Missing Supabase configuration');
+    }
+
+    // Get JWT from authorization header
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(JSON.stringify({
+        error: 'Authorization header required'
+      }), {
+        status: 401,
+        headers: {
+          ...corsHeaders,
+          'Content-Type': 'application/json'
+        }
+      });
+    }
+
+    const jwt = authHeader.replace('Bearer ', '');
+
+    // Create Supabase client with user's JWT for proper authentication
+    const supabase = createClient(supabaseUrl, supabaseAnonKey, {
+      global: {
+        headers: { Authorization: authHeader }
+      }
+    });
+
+    // Verify the JWT and get user
+    const { data: { user }, error: authError } = await supabase.auth.getUser(jwt);
+
+    if (authError || !user) {
+      console.error('Authentication failed:', authError);
+      return new Response(JSON.stringify({
+        error: 'Invalid authorization token'
+      }), {
+        status: 401,
+        headers: {
+          ...corsHeaders,
+          'Content-Type': 'application/json'
+        }
+      });
+    }
+
+    // Verify user is authenticated (double-check)
+    if (!user.id) {
+      return new Response(JSON.stringify({
+        error: 'Authentication required'
+      }), {
+        status: 401,
+        headers: {
+          ...corsHeaders,
+          'Content-Type': 'application/json'
+        }
+      });
+    }
+
+    const url = new URL(req.url);
+    const pathParts = url.pathname.split('/').filter(Boolean);
+
+    // Route to different endpoints based on path
+    if (pathParts.length < 1) {
+      return new Response(JSON.stringify({
+        error: 'Invalid endpoint'
+      }), {
+        status: 400,
+        headers: {
+          ...corsHeaders,
+          'Content-Type': 'application/json'
+        }
+      });
+    }
+
+    const action = pathParts[0];
+
+    switch (action) {
+      case 'register-shop': {
+        // POST /register-shop
+        // Register a store with the scraper
+        if (req.method !== 'POST') {
+          return new Response(JSON.stringify({
+            error: 'Method not allowed'
+          }), {
+            status: 405,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const { store_id } = await req.json();
+        if (!store_id) {
+          return new Response(JSON.stringify({
+            error: 'store_id is required'
+          }), {
+            status: 400,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const store = await getStoreScraperConfig(supabase, store_id, user.id);
+
+        if (store.scraper_registered) {
+          return new Response(JSON.stringify({
+            success: true,
+            message: 'Store already registered',
+            shop_id: store_id
+          }), {
+            status: 200,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        // Create scraper client with store configuration
+        const scraperClient = await createScraperClient({
+          scraper_api_url: store.scraper_api_url,
+          scraper_api_secret: store.scraper_api_secret
+        });
+
+        // Register shop with scraper using store_id as custom_id
+        const job = await scraperClient.registerShop(store.store_url, store.id);
+
+        // Set up webhook
+        const webhookUrl = `${Deno.env.get('SUPABASE_URL')}/functions/v1/scraper-webhook`;
+        try {
+          await scraperClient.setWebhook(store.id, webhookUrl);
+          console.log(`Webhook configured for store ${store.id}`);
+        } catch (webhookError) {
+          console.warn(`Failed to configure webhook for store ${store.id}:`, webhookError);
+          // Continue anyway - webhook is optional
+        }
+
+        // Enable scheduled scraping
+        try {
+          await scraperClient.setScheduling(store.id, true);
+          console.log(`Scheduled scraping enabled for store ${store.id}`);
+        } catch (scheduleError) {
+          console.warn(`Failed to enable scheduling for store ${store.id}:`, scheduleError);
+          // Continue anyway
+        }
+
+        // Update database
+        await updateStoreScraperStatus(supabase, store.id, {
+          scraper_registered: true
+        });
+
+        return new Response(JSON.stringify({
+          success: true,
+          message: 'Store registered with scraper successfully',
+          job_id: job.id,
+          shop_id: store.id
+        }), {
+          status: 201,
+          headers: {
+            ...corsHeaders,
+            'Content-Type': 'application/json'
+          }
+        });
+      }
+
+      case 'shop-status': {
+        // GET /shop-status?store_id=xxx
+        if (req.method !== 'GET') {
+          return new Response(JSON.stringify({
+            error: 'Method not allowed'
+          }), {
+            status: 405,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const storeId = url.searchParams.get('store_id');
+        if (!storeId) {
+          return new Response(JSON.stringify({
+            error: 'store_id parameter is required'
+          }), {
+            status: 400,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const store = await getStoreScraperConfig(supabase, storeId, user.id);
+
+        if (!store.scraper_registered) {
+          return new Response(JSON.stringify({
+            registered: false,
+            enabled: store.scraper_enabled,
+            message: 'Store not registered with scraper'
+          }), {
+            status: 200,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        // Get shop status from scraper
+        const scraperClient = await createScraperClient({
+          scraper_api_url: store.scraper_api_url,
+          scraper_api_secret: store.scraper_api_secret
+        });
+
+        try {
+          const shopData = await scraperClient.getShop(store.id);
+          return new Response(JSON.stringify({
+            registered: true,
+            enabled: store.scraper_enabled,
+            shop_data: shopData
+          }), {
+            status: 200,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        } catch (error) {
+          console.error(`Failed to get shop status for ${storeId}:`, error);
+          return new Response(JSON.stringify({
+            registered: true,
+            enabled: store.scraper_enabled,
+            error: 'Failed to fetch shop data from scraper',
+            message: error.message
+          }), {
+            status: 200,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+      }
+
+      case 'shop-content': {
+        // GET /shop-content?store_id=xxx&content_type=faq&limit=50
+        if (req.method !== 'GET') {
+          return new Response(JSON.stringify({
+            error: 'Method not allowed'
+          }), {
+            status: 405,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const storeId = url.searchParams.get('store_id');
+        if (!storeId) {
+          return new Response(JSON.stringify({
+            error: 'store_id parameter is required'
+          }), {
+            status: 400,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const store = await getStoreScraperConfig(supabase, storeId, user.id);
+
+        if (!store.scraper_registered) {
+          return new Response(JSON.stringify({
+            error: 'Store not registered with scraper'
+          }), {
+            status: 404,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const scraperClient = await createScraperClient({
+          scraper_api_url: store.scraper_api_url,
+          scraper_api_secret: store.scraper_api_secret
+        });
+
+        // Build filter from query parameters
+        const filter: any = {};
+        const contentType = url.searchParams.get('content_type');
+        const dateFrom = url.searchParams.get('date_from');
+        const dateTo = url.searchParams.get('date_to');
+        const limit = url.searchParams.get('limit');
+
+        if (contentType) filter.content_type = contentType;
+        if (dateFrom) filter.date_from = dateFrom;
+        if (dateTo) filter.date_to = dateTo;
+        if (limit) filter.limit = parseInt(limit, 10);
+
+        const content = await scraperClient.getShopContent(store.id, filter);
+
+        return new Response(JSON.stringify(content), {
+          status: 200,
+          headers: {
+            ...corsHeaders,
+            'Content-Type': 'application/json'
+          }
+        });
+      }
+
+      case 'custom-urls': {
+        const storeId = url.searchParams.get('store_id') || (await req.json())?.store_id;
+        if (!storeId) {
+          return new Response(JSON.stringify({
+            error: 'store_id is required'
+          }), {
+            status: 400,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const store = await getStoreScraperConfig(supabase, storeId, user.id);
+
+        if (!store.scraper_registered) {
+          return new Response(JSON.stringify({
+            error: 'Store not registered with scraper'
+          }), {
+            status: 404,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const scraperClient = await createScraperClient({
+          scraper_api_url: store.scraper_api_url,
+          scraper_api_secret: store.scraper_api_secret
+        });
+
+        if (req.method === 'GET') {
+          // List custom URLs
+          const customUrls = await scraperClient.listCustomUrls(store.id);
+          return new Response(JSON.stringify(customUrls), {
+            status: 200,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        } else if (req.method === 'POST') {
+          // Add custom URL
+          const { url: customUrl, content_type } = await req.json();
+
+          if (!customUrl || !content_type) {
+            return new Response(JSON.stringify({
+              error: 'url and content_type are required'
+            }), {
+              status: 400,
+              headers: {
+                ...corsHeaders,
+                'Content-Type': 'application/json'
+              }
+            });
+          }
+
+          // Validate same domain
+          if (!validateSameDomain(store.store_url, customUrl)) {
+            return new Response(JSON.stringify({
+              error: 'Custom URL must be from the same domain as the store'
+            }), {
+              status: 400,
+              headers: {
+                ...corsHeaders,
+                'Content-Type': 'application/json'
+              }
+            });
+          }
+
+          const result = await scraperClient.addCustomUrl(store.id, customUrl, content_type);
+
+          return new Response(JSON.stringify(result), {
+            status: 201,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        return new Response(JSON.stringify({
+          error: 'Method not allowed'
+        }), {
+          status: 405,
+          headers: {
+            ...corsHeaders,
+            'Content-Type': 'application/json'
+          }
+        });
+      }
+
+      case 'scheduling': {
+        // PATCH /scheduling
+        if (req.method !== 'PATCH') {
+          return new Response(JSON.stringify({
+            error: 'Method not allowed'
+          }), {
+            status: 405,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const { store_id, enabled } = await req.json();
+        if (!store_id || typeof enabled !== 'boolean') {
+          return new Response(JSON.stringify({
+            error: 'store_id and enabled (boolean) are required'
+          }), {
+            status: 400,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const store = await getStoreScraperConfig(supabase, store_id, user.id);
+
+        if (!store.scraper_registered) {
+          return new Response(JSON.stringify({
+            error: 'Store not registered with scraper'
+          }), {
+            status: 404,
+            headers: {
+              ...corsHeaders,
+              'Content-Type': 'application/json'
+            }
+          });
+        }
+
+        const scraperClient = await createScraperClient({
+          scraper_api_url: store.scraper_api_url,
+          scraper_api_secret: store.scraper_api_secret
+        });
+
+        await scraperClient.setScheduling(store.id, enabled);
+
+        return new Response(JSON.stringify({
+          success: true,
+          message: `Scheduling ${enabled ? 'enabled' : 'disabled'}`
+        }), {
+          status: 200,
+          headers: {
+            ...corsHeaders,
+            'Content-Type': 'application/json'
+          }
+        });
+      }
+
+      default:
+        return new Response(JSON.stringify({
+          error: 'Unknown endpoint'
+        }), {
+          status: 404,
+          headers: {
+            ...corsHeaders,
+            'Content-Type': 'application/json'
+          }
+        });
+    }
+  } catch (error) {
+    console.error('Error in scraper management API:', error);
+    return new Response(JSON.stringify({
+      error: 'Internal server error',
+      message: error instanceof Error ? error.message : 'Unknown error'
+    }), {
+      status: 500,
+      headers: {
+        ...corsHeaders,
+        'Content-Type': 'application/json'
+      }
+    });
+  }
+});

+ 27 - 33
shopcall.ai-main/vite.config.ts

@@ -31,11 +31,17 @@ export default defineConfig(({ mode }) => ({
     rollupOptions: {
     rollupOptions: {
       output: {
       output: {
         manualChunks: {
         manualChunks: {
-          // Vendor chunk for React ecosystem
-          'vendor-react': ['react', 'react-dom', 'react-router-dom'],
+          // Core vendor libraries - keep these together to avoid dependency issues
+          'vendor': [
+            'react',
+            'react-dom',
+            'react-router-dom',
+            '@tanstack/react-query',
+            '@supabase/supabase-js',
+          ],
 
 
-          // UI library chunks (large Radix UI components)
-          'vendor-ui-core': [
+          // All Radix UI components together to avoid circular dependencies
+          'vendor-radix': [
             '@radix-ui/react-dialog',
             '@radix-ui/react-dialog',
             '@radix-ui/react-dropdown-menu',
             '@radix-ui/react-dropdown-menu',
             '@radix-ui/react-popover',
             '@radix-ui/react-popover',
@@ -43,26 +49,17 @@ export default defineConfig(({ mode }) => ({
             '@radix-ui/react-tabs',
             '@radix-ui/react-tabs',
             '@radix-ui/react-toast',
             '@radix-ui/react-toast',
             '@radix-ui/react-tooltip',
             '@radix-ui/react-tooltip',
-          ],
-          'vendor-ui-forms': [
             '@radix-ui/react-checkbox',
             '@radix-ui/react-checkbox',
             '@radix-ui/react-radio-group',
             '@radix-ui/react-radio-group',
             '@radix-ui/react-slider',
             '@radix-ui/react-slider',
             '@radix-ui/react-switch',
             '@radix-ui/react-switch',
             '@radix-ui/react-label',
             '@radix-ui/react-label',
-            'react-hook-form',
-            '@hookform/resolvers',
-            'zod',
-          ],
-          'vendor-ui-layout': [
             '@radix-ui/react-accordion',
             '@radix-ui/react-accordion',
             '@radix-ui/react-collapsible',
             '@radix-ui/react-collapsible',
             '@radix-ui/react-navigation-menu',
             '@radix-ui/react-navigation-menu',
             '@radix-ui/react-scroll-area',
             '@radix-ui/react-scroll-area',
             '@radix-ui/react-separator',
             '@radix-ui/react-separator',
             '@radix-ui/react-aspect-ratio',
             '@radix-ui/react-aspect-ratio',
-          ],
-          'vendor-ui-misc': [
             '@radix-ui/react-alert-dialog',
             '@radix-ui/react-alert-dialog',
             '@radix-ui/react-avatar',
             '@radix-ui/react-avatar',
             '@radix-ui/react-context-menu',
             '@radix-ui/react-context-menu',
@@ -74,19 +71,28 @@ export default defineConfig(({ mode }) => ({
             '@radix-ui/react-toggle-group',
             '@radix-ui/react-toggle-group',
           ],
           ],
 
 
-          // Data & State management
-          'vendor-data': [
-            '@tanstack/react-query',
-            '@supabase/supabase-js',
-          ],
-
-          // Utilities & Styling
+          // Utilities and styling
           'vendor-utils': [
           'vendor-utils': [
             'clsx',
             'clsx',
             'class-variance-authority',
             'class-variance-authority',
             'tailwind-merge',
             'tailwind-merge',
             'tailwindcss-animate',
             'tailwindcss-animate',
             'cmdk',
             'cmdk',
+            'lucide-react',
+            'next-themes',
+            'sonner',
+            'vaul',
+            'embla-carousel-react',
+            'react-day-picker',
+            'react-resizable-panels',
+            'input-otp',
+          ],
+
+          // Forms and validation
+          'vendor-forms': [
+            'react-hook-form',
+            '@hookform/resolvers',
+            'zod',
           ],
           ],
 
 
           // Internationalization
           // Internationalization
@@ -96,23 +102,11 @@ export default defineConfig(({ mode }) => ({
             'react-i18next',
             'react-i18next',
           ],
           ],
 
 
-          // Charts & Data Visualization
+          // Charts
           'vendor-charts': [
           'vendor-charts': [
             'recharts',
             'recharts',
             'date-fns',
             'date-fns',
           ],
           ],
-
-          // UI Enhancements
-          'vendor-ui-enhanced': [
-            'lucide-react',
-            'next-themes',
-            'sonner',
-            'vaul',
-            'embla-carousel-react',
-            'react-day-picker',
-            'react-resizable-panels',
-            'input-otp',
-          ],
         },
         },
         // Optimize chunk loading
         // Optimize chunk loading
         chunkFileNames: (chunkInfo) => {
         chunkFileNames: (chunkInfo) => {