|
|
@@ -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)
|