176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
|
|
import crypto from 'crypto';
|
|
import axios from 'axios';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import FormData from 'form-data';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const ETSY_KEY_STRING = process.env.ETSY_KEY_STRING || '';
|
|
// Shared secret is usually not needed for v3 PKCE flows unless specific scopes/endpoints require signing,
|
|
// but we keep it in env just in case.
|
|
const ETSY_REDIRECT_URI = 'http://localhost:3001/api/etsy/callback';
|
|
|
|
export class EtsyAuthService {
|
|
|
|
// Generate PKCE Challenge
|
|
static generateChallenge() {
|
|
const codeVerifier = crypto.randomBytes(32).toString('base64')
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
|
|
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64')
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
|
|
const state = crypto.randomBytes(16).toString('hex');
|
|
|
|
return { codeVerifier, codeChallenge, state };
|
|
}
|
|
|
|
// Get Auth URL
|
|
static getAuthUrl(codeChallenge: string, state: string, scopes: string[]) {
|
|
const params = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: ETSY_KEY_STRING,
|
|
redirect_uri: ETSY_REDIRECT_URI,
|
|
scope: scopes.join(' '),
|
|
state: state,
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
return `https://www.etsy.com/oauth/connect?${params.toString()}`;
|
|
}
|
|
|
|
// Exchange Code for Token
|
|
static async getAccessToken(code: string, codeVerifier: string) {
|
|
try {
|
|
const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', {
|
|
grant_type: 'authorization_code',
|
|
client_id: ETSY_KEY_STRING,
|
|
redirect_uri: ETSY_REDIRECT_URI,
|
|
code: code,
|
|
code_verifier: codeVerifier,
|
|
});
|
|
return response.data; // { access_token, refresh_token, expires_in, etc }
|
|
} catch (error: any) {
|
|
console.error('Etsy Token Exchange Error:', error.response?.data || error.message);
|
|
throw new Error('Failed to exchange code for token');
|
|
}
|
|
}
|
|
|
|
// Refresh Token
|
|
static async refreshToken(refreshToken: string) {
|
|
try {
|
|
const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', {
|
|
grant_type: 'refresh_token',
|
|
client_id: ETSY_KEY_STRING,
|
|
refresh_token: refreshToken,
|
|
});
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error('Etsy Token Refresh Error:', error.response?.data || error.message);
|
|
throw new Error('Failed to refresh token');
|
|
}
|
|
}
|
|
|
|
// Get User/Shop ID
|
|
static async getSelf(accessToken: string) {
|
|
try {
|
|
const response = await axios.get('https://api.etsy.com/v3/application/users/me', {
|
|
headers: {
|
|
'x-api-key': ETSY_KEY_STRING,
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
});
|
|
// Response usually contains user_id. From there we can get the shop.
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error('getSelf Error:', error.response?.data || error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async getShop(userId: string | number, accessToken: string) {
|
|
try {
|
|
const response = await axios.get(`https://api.etsy.com/v3/application/users/${userId}/shops`, {
|
|
headers: {
|
|
'x-api-key': ETSY_KEY_STRING,
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
});
|
|
return response.data; // Returns list of shops (usually 1)
|
|
} catch (error: any) {
|
|
console.error('getShop Error:', error.response?.data || error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// --- LISTING MANAGEMENT (V3) ---
|
|
|
|
static async createDraftListing(shopId: string, accessToken: string, data: {
|
|
title: string,
|
|
description: string,
|
|
price: number,
|
|
quantity: number,
|
|
who_made: 'i_did' | 'collective' | 'someone_else',
|
|
when_made: 'made_to_order' | '2020_2025' | '2010_2019' | string,
|
|
is_supply: boolean,
|
|
taxonomy_id: number
|
|
}) {
|
|
try {
|
|
const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings`, data, {
|
|
headers: {
|
|
'x-api-key': ETSY_KEY_STRING,
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
return response.data; // { listing_id, ... }
|
|
} catch (error: any) {
|
|
console.error('createDraftListing Error:', error.response?.data || error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async uploadListingImage(shopId: string, listingId: number | string, accessToken: string, imageBuffer: Buffer, filename: string) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('image', imageBuffer, { filename });
|
|
|
|
const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings/${listingId}/images`, formData, {
|
|
headers: {
|
|
'x-api-key': ETSY_KEY_STRING,
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
...formData.getHeaders()
|
|
}
|
|
});
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error('uploadListingImage Error:', error.response?.data || error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper to check and refresh token if needed
|
|
static async ensureValidToken(shopRecord: any) {
|
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
if (shopRecord.expiresAt > now + BigInt(60)) {
|
|
return shopRecord.accessToken;
|
|
}
|
|
|
|
console.log(`[Etsy] Refreshing token for shop ${shopRecord.shopName}`);
|
|
const tokenData = await this.refreshToken(shopRecord.refreshToken);
|
|
|
|
const updated = await prisma.etsyShop.update({
|
|
where: { id: shopRecord.id },
|
|
data: {
|
|
accessToken: tokenData.access_token,
|
|
refreshToken: tokenData.refresh_token,
|
|
expiresAt: BigInt(Math.floor(Date.now() / 1000) + tokenData.expires_in)
|
|
}
|
|
});
|
|
|
|
return updated.accessToken;
|
|
}
|
|
}
|