* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
179 lines
5.2 KiB
JavaScript
179 lines
5.2 KiB
JavaScript
import { randomBytes, createHash } from 'crypto';
|
|
import { setSecret } from './secret-store.mjs';
|
|
|
|
const ETSY_BASE = 'https://api.etsy.com/v3';
|
|
const ETSY_AUTH = 'https://www.etsy.com/oauth/connect';
|
|
const ETSY_TOKEN = 'https://api.etsy.com/v3/public/oauth/token';
|
|
|
|
let pkceStore = {};
|
|
|
|
function setEnvVar(key, value) {
|
|
setSecret(key, value, { source: 'oauth' });
|
|
}
|
|
|
|
function generatePKCE() {
|
|
const verifier = randomBytes(32).toString('base64url');
|
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
return { verifier, challenge };
|
|
}
|
|
|
|
export function getAuthUrl(redirectUri) {
|
|
const apiKey = process.env.ETSY_API_KEY;
|
|
if (!apiKey) throw new Error('ETSY_API_KEY nicht gesetzt');
|
|
|
|
const { verifier, challenge } = generatePKCE();
|
|
const state = randomBytes(16).toString('hex');
|
|
pkceStore[state] = verifier;
|
|
|
|
const params = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: apiKey,
|
|
redirect_uri: redirectUri,
|
|
scope: 'listings_w listings_r shops_r images_w',
|
|
state,
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
return `${ETSY_AUTH}?${params}`;
|
|
}
|
|
|
|
export async function handleCallback(code, state, redirectUri) {
|
|
const verifier = pkceStore[state];
|
|
if (!verifier) throw new Error('Ungültiger OAuth-State');
|
|
delete pkceStore[state];
|
|
|
|
const apiKey = process.env.ETSY_API_KEY;
|
|
const res = await fetch(ETSY_TOKEN, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: apiKey,
|
|
redirect_uri: redirectUri,
|
|
code,
|
|
code_verifier: verifier,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`Etsy Token-Fehler: ${err}`);
|
|
}
|
|
|
|
const tokens = await res.json();
|
|
setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token);
|
|
setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token);
|
|
return tokens;
|
|
}
|
|
|
|
async function refreshToken() {
|
|
const apiKey = process.env.ETSY_API_KEY;
|
|
const refreshToken = process.env.ETSY_REFRESH_TOKEN;
|
|
if (!refreshToken) throw new Error('Kein Etsy Refresh-Token');
|
|
|
|
const res = await fetch(ETSY_TOKEN, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
client_id: apiKey,
|
|
refresh_token: refreshToken,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Etsy Token-Refresh fehlgeschlagen');
|
|
const tokens = await res.json();
|
|
setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token);
|
|
setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token);
|
|
return tokens.access_token;
|
|
}
|
|
|
|
async function etsyFetch(path, opts = {}) {
|
|
let token = process.env.ETSY_ACCESS_TOKEN;
|
|
if (!token) throw new Error('Nicht mit Etsy verbunden');
|
|
|
|
const apiKey = process.env.ETSY_API_KEY;
|
|
const headers = { 'x-api-key': apiKey, Authorization: `Bearer ${token}`, ...opts.headers };
|
|
let res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers });
|
|
|
|
if (res.status === 401) {
|
|
token = await refreshToken();
|
|
headers.Authorization = `Bearer ${token}`;
|
|
res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers });
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`Etsy API Fehler (${res.status}): ${err}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function getShopId() {
|
|
const data = await etsyFetch('/application/users/me');
|
|
const userId = data.user_id;
|
|
const shop = await etsyFetch(`/application/users/${userId}/shops`);
|
|
if (!shop.results?.length) throw new Error('Kein Etsy-Shop gefunden');
|
|
return shop.results[0].shop_id;
|
|
}
|
|
|
|
export async function createEtsyListing(listing, shopId) {
|
|
const tags = Array.isArray(listing.tags) ? listing.tags.slice(0, 13) : [];
|
|
const price = listing.price || listing.suggested_price || 10;
|
|
|
|
const body = {
|
|
quantity: 1,
|
|
title: listing.title,
|
|
description: listing.description,
|
|
price: price,
|
|
who_made: 'someone_else',
|
|
when_made: '2020_2025',
|
|
taxonomy_id: 1,
|
|
tags,
|
|
type: 'physical',
|
|
shipping_profile_id: null,
|
|
state: 'draft',
|
|
};
|
|
|
|
const result = await etsyFetch(`/application/shops/${shopId}/listings`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function uploadEtsyImage(shopId, listingId, imageBuffer, filename) {
|
|
const formData = new FormData();
|
|
formData.append('image', new Blob([imageBuffer]), filename);
|
|
|
|
const token = process.env.ETSY_ACCESS_TOKEN;
|
|
const apiKey = process.env.ETSY_API_KEY;
|
|
|
|
const res = await fetch(`${ETSY_BASE}/application/shops/${shopId}/listings/${listingId}/images`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-api-key': apiKey,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`Bild-Upload fehlgeschlagen: ${err}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export function isConnected() {
|
|
return !!(process.env.ETSY_ACCESS_TOKEN && process.env.ETSY_API_KEY);
|
|
}
|
|
|
|
export async function getEtsyListing(listingId) {
|
|
if (!listingId) throw new Error('Etsy Listing-ID fehlt');
|
|
return etsyFetch(`/application/listings/${encodeURIComponent(listingId)}`);
|
|
}
|