Ver código fonte

fix: implement HTTP/1.0 for all ShopRenter API requests #79

- Add makeHttp10Request() function using raw TCP/TLS connections
- Replace all fetch() calls with HTTP/1.0 implementation
- Fixes 'Unauthorized - token may be invalid' error caused by HTTP/2
- Updates shopRenterApiRequest(), getTokenWithClientCredentials(), refreshAccessToken()
- Uses Connection: close header as required by ShopRenter API
- Handles gzip decompression for API responses
Claude 5 meses atrás
pai
commit
0902ed5dd8
1 arquivos alterados com 271 adições e 52 exclusões
  1. 271 52
      supabase/functions/_shared/shoprenter-client.ts

+ 271 - 52
supabase/functions/_shared/shoprenter-client.ts

@@ -8,6 +8,220 @@ export interface ShopRenterTokens {
   shopname: string
 }
 
+/**
+ * Makes an HTTP/1.0 request using raw TCP/TLS connection
+ * This is necessary because ShopRenter API requires HTTP/1.0
+ * and Deno's fetch API uses HTTP/2 which causes authentication issues
+ */
+async function makeHttp10Request(
+  hostname: string,
+  path: string,
+  method: string,
+  headers: Record<string, string>,
+  body?: string
+): Promise<{ status: number; statusText: string; headers: Record<string, string>; body: string }> {
+  // Open TLS connection
+  const conn = await Deno.connectTls({
+    hostname: hostname,
+    port: 443,
+  })
+
+  try {
+    // Build HTTP/1.0 request (ShopRenter requires HTTP/1.0)
+    const requestLines: string[] = [
+      `${method} ${path} HTTP/1.0`,
+      `Host: ${hostname}`,
+    ]
+
+    // Add all headers
+    for (const [key, value] of Object.entries(headers)) {
+      requestLines.push(`${key}: ${value}`)
+    }
+
+    // Add Connection: close for HTTP/1.0
+    requestLines.push('Connection: close')
+
+    // Empty line to end headers
+    requestLines.push('')
+    requestLines.push('')
+
+    // Build the complete request
+    let request = requestLines.join('\r\n')
+
+    // Add body if present (after the double CRLF)
+    if (body) {
+      request = request + body
+    }
+
+    console.log('[HTTP/1.0] Sending request to:', hostname + path)
+
+    // Send request
+    const encoder = new TextEncoder()
+    await conn.write(encoder.encode(request))
+
+    // Read response as binary data
+    const chunks: Uint8Array[] = []
+    const buffer = new Uint8Array(64 * 1024) // 64KB buffer per chunk
+    let totalBytes = 0
+
+    while (true) {
+      try {
+        const n = await conn.read(buffer)
+        if (n === null) break
+
+        // Copy the chunk to preserve it
+        const chunk = new Uint8Array(n)
+        chunk.set(buffer.subarray(0, n))
+        chunks.push(chunk)
+        totalBytes += n
+      } catch (e) {
+        // Handle UnexpectedEof during read - connection closed by server
+        // This is expected when server sends Connection: close
+        if (e instanceof Deno.errors.UnexpectedEof ||
+            (e instanceof Error && e.name === 'UnexpectedEof')) {
+          console.log('[HTTP/1.0] Connection closed by server (expected with Connection: close)')
+          break
+        }
+        throw e
+      }
+    }
+
+    console.log('[HTTP/1.0] Received response bytes:', totalBytes)
+
+    // Concatenate all chunks into a single Uint8Array
+    const responseData = new Uint8Array(totalBytes)
+    let offset = 0
+    for (const chunk of chunks) {
+      responseData.set(chunk, offset)
+      offset += chunk.length
+    }
+
+    // Parse HTTP response
+    const decoder = new TextDecoder()
+
+    // Search for \r\n\r\n or \n\n in the binary data
+    let headerEndIndex = -1
+    let headerSeparatorLength = 0
+
+    // Look for \r\n\r\n (most common)
+    for (let i = 0; i < responseData.length - 3; i++) {
+      if (responseData[i] === 13 && responseData[i + 1] === 10 &&
+          responseData[i + 2] === 13 && responseData[i + 3] === 10) {
+        headerEndIndex = i
+        headerSeparatorLength = 4
+        break
+      }
+    }
+
+    // If not found, look for \n\n
+    if (headerEndIndex === -1) {
+      for (let i = 0; i < responseData.length - 1; i++) {
+        if (responseData[i] === 10 && responseData[i + 1] === 10) {
+          headerEndIndex = i
+          headerSeparatorLength = 2
+          break
+        }
+      }
+    }
+
+    if (headerEndIndex === -1) {
+      const preview = decoder.decode(responseData.subarray(0, Math.min(200, responseData.length)))
+      console.error('[HTTP/1.0] Could not find header separator. Response preview:', preview)
+      throw new Error('Invalid HTTP response: no header/body separator found')
+    }
+
+    // Decode headers only (they're ASCII)
+    const headerBytes = responseData.subarray(0, headerEndIndex)
+    const headerSection = decoder.decode(headerBytes)
+
+    // Body is everything after the separator
+    const bodyBytes = responseData.subarray(headerEndIndex + headerSeparatorLength)
+
+    // Parse status line
+    const lines = headerSection.split(/\r?\n/)
+    const statusLine = lines[0]
+    const statusMatch = statusLine.match(/HTTP\/1\.[01]\s+(\d+)\s+(.*)/)
+
+    if (!statusMatch) {
+      console.error('[HTTP/1.0] Invalid status line:', statusLine)
+      throw new Error(`Invalid HTTP status line: ${statusLine}`)
+    }
+
+    const status = parseInt(statusMatch[1])
+    const statusText = statusMatch[2]
+
+    // Parse headers
+    const responseHeaders: Record<string, string> = {}
+    for (let i = 1; i < lines.length; i++) {
+      const colonIndex = lines[i].indexOf(':')
+      if (colonIndex > 0) {
+        const key = lines[i].substring(0, colonIndex).trim()
+        const value = lines[i].substring(colonIndex + 1).trim()
+        responseHeaders[key] = value
+      }
+    }
+
+    console.log('[HTTP/1.0] Response received:', status, statusText)
+
+    // Decode body (handle gzip if present)
+    let bodyText: string
+    const contentEncoding = responseHeaders['Content-Encoding'] || responseHeaders['content-encoding']
+
+    if (contentEncoding === 'gzip') {
+      // Decompress gzip
+      try {
+        const decompressed = new DecompressionStream('gzip')
+        const writer = decompressed.writable.getWriter()
+        await writer.write(bodyBytes)
+        await writer.close()
+
+        const reader = decompressed.readable.getReader()
+        const decompressedChunks: Uint8Array[] = []
+        while (true) {
+          const { done, value } = await reader.read()
+          if (done) break
+          decompressedChunks.push(value)
+        }
+
+        const decompressedData = new Uint8Array(
+          decompressedChunks.reduce((acc, chunk) => acc + chunk.length, 0)
+        )
+        let pos = 0
+        for (const chunk of decompressedChunks) {
+          decompressedData.set(chunk, pos)
+          pos += chunk.length
+        }
+
+        bodyText = decoder.decode(decompressedData)
+      } catch (e) {
+        console.error('[HTTP/1.0] Error decompressing gzip:', e)
+        bodyText = decoder.decode(bodyBytes)
+      }
+    } else {
+      bodyText = decoder.decode(bodyBytes)
+    }
+
+    return {
+      status,
+      statusText,
+      headers: responseHeaders,
+      body: bodyText,
+    }
+  } finally {
+    try {
+      conn.close()
+    } catch (e) {
+      // Gracefully handle connection closure errors
+      if (e instanceof Deno.errors.UnexpectedEof ||
+          (e instanceof Error && e.name === 'UnexpectedEof')) {
+        console.log('[HTTP/1.0] Connection already closed by server')
+      } else {
+        console.error('[HTTP/1.0] Error closing connection:', e)
+      }
+    }
+  }
+}
+
 export interface ShopRenterProduct {
   id: string
   name: string
@@ -148,31 +362,33 @@ export async function getValidAccessToken(storeId: string): Promise<string> {
 
 // Get access token using client_credentials grant
 async function getTokenWithClientCredentials(shopname: string, clientId: string, clientSecret: string) {
-  const tokenUrl = `https://oauth.app.shoprenter.net/${shopname}/app/token`
+  const hostname = 'oauth.app.shoprenter.net'
+  const path = `/${shopname}/app/token`
 
   console.log(`[ShopRenter] Requesting token for ${shopname} using client_credentials`)
 
+  const requestBody = JSON.stringify({
+    grant_type: 'client_credentials',
+    client_id: clientId,
+    client_secret: clientSecret
+  })
+
+  const requestHeaders: Record<string, string> = {
+    'Content-Type': 'application/json',
+    'Accept': 'application/json',
+    'Content-Length': requestBody.length.toString()
+  }
+
   try {
-    const response = await fetch(tokenUrl, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        'Accept': 'application/json'
-      },
-      body: JSON.stringify({
-        grant_type: 'client_credentials',
-        client_id: clientId,
-        client_secret: clientSecret
-      })
-    })
+    // Use HTTP/1.0 request (required by ShopRenter API)
+    const response = await makeHttp10Request(hostname, path, 'POST', requestHeaders, requestBody)
 
-    if (!response.ok) {
-      const errorData = await response.text()
-      console.error('[ShopRenter] Token request error:', errorData)
-      throw new Error(`Failed to get access token: ${response.status} ${errorData}`)
+    if (response.status !== 200) {
+      console.error('[ShopRenter] Token request error:', response.body)
+      throw new Error(`Failed to get access token: ${response.status} ${response.body}`)
     }
 
-    const data = await response.json()
+    const data = JSON.parse(response.body)
     console.log(`[ShopRenter] Access token obtained for ${shopname}`)
     return data
   } catch (error) {
@@ -190,30 +406,32 @@ async function refreshAccessToken(shopname: string, refreshToken: string) {
     throw new Error('ShopRenter credentials not configured')
   }
 
-  const tokenUrl = `https://${shopname}.shoprenter.hu/oauth/token`
+  const hostname = `${shopname}.shoprenter.hu`
+  const path = '/oauth/token'
+
+  const requestBody = JSON.stringify({
+    grant_type: 'refresh_token',
+    client_id: clientId,
+    client_secret: clientSecret,
+    refresh_token: refreshToken
+  })
+
+  const requestHeaders: Record<string, string> = {
+    'Content-Type': 'application/json',
+    'Accept': 'application/json',
+    'Content-Length': requestBody.length.toString()
+  }
 
   try {
-    const response = await fetch(tokenUrl, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        'Accept': 'application/json'
-      },
-      body: JSON.stringify({
-        grant_type: 'refresh_token',
-        client_id: clientId,
-        client_secret: clientSecret,
-        refresh_token: refreshToken
-      })
-    })
+    // Use HTTP/1.0 request (required by ShopRenter API)
+    const response = await makeHttp10Request(hostname, path, 'POST', requestHeaders, requestBody)
 
-    if (!response.ok) {
-      const errorData = await response.text()
-      console.error('[ShopRenter] Token refresh error:', errorData)
+    if (response.status !== 200) {
+      console.error('[ShopRenter] Token refresh error:', response.body)
       throw new Error('Failed to refresh token')
     }
 
-    const data = await response.json()
+    const data = JSON.parse(response.body)
     console.log(`[ShopRenter] Token refreshed for ${shopname}`)
     return data
   } catch (error) {
@@ -248,29 +466,30 @@ export async function shopRenterApiRequest(
   // Get valid access token
   const accessToken = await getValidAccessToken(storeId)
 
-  // Build API URL
-  const apiUrl = `https://${store.store_name}.api2.myshoprenter.hu/api${endpoint}`
+  // Build API URL components
+  const hostname = `${store.store_name}.api2.myshoprenter.hu`
+  const path = `/api${endpoint}`
 
-  // Make request
-  const options: RequestInit = {
-    method,
-    headers: {
-      'Authorization': `Bearer ${accessToken}`,
-      'Content-Type': 'application/json',
-      'Accept': 'application/json'
-    }
+  // Prepare headers
+  const requestHeaders: Record<string, string> = {
+    'Authorization': `Bearer ${accessToken}`,
+    'Content-Type': 'application/json',
+    'Accept': 'application/json'
   }
 
+  // Prepare body if needed
+  let requestBody: string | undefined = undefined
   if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
-    options.body = JSON.stringify(body)
+    requestBody = JSON.stringify(body)
+    requestHeaders['Content-Length'] = requestBody.length.toString()
   }
 
   try {
-    const response = await fetch(apiUrl, options)
+    // Use HTTP/1.0 request (required by ShopRenter API)
+    const response = await makeHttp10Request(hostname, path, method, requestHeaders, requestBody)
 
-    if (!response.ok) {
-      const errorData = await response.text()
-      console.error(`[ShopRenter] API error (${response.status}):`, errorData)
+    if (response.status !== 200 && response.status !== 201) {
+      console.error(`[ShopRenter] API error (${response.status}):`, response.body)
 
       // Handle 401 - token might be invalid
       if (response.status === 401) {
@@ -280,7 +499,7 @@ export async function shopRenterApiRequest(
       throw new Error(`API request failed with status ${response.status}`)
     }
 
-    const data = await response.json()
+    const data = JSON.parse(response.body)
     return data
   } catch (error) {
     console.error('[ShopRenter] API request error:', error)