added on WebSTLs (webstls.com) at 2026-07-01 22:26:39
| 1 | package MODS::WebSTLs::Marketplaces; | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs -- Marketplace integrations registry. | |
| 4 | # | |
| 5 | # This file is the single source of truth for which marketplaces the | |
| 6 | # platform supports. Each entry describes: | |
| 7 | # - slug: short identifier used in URLs and the DB | |
| 8 | # - name: creator-facing label | |
| 9 | # - tagline: one-line description of the platform | |
| 10 | # - status: 'live' (API adapter exists) | 'manual' (export only) | |
| 11 | # | 'planned' (placeholder, no adapter yet) | |
| 12 | # - sells: what the platform is good for (free text) | |
| 13 | # - sells_digital: 1 if accepts digital STL/file listings | |
| 14 | # - sells_physical: 1 if accepts physical (3D-printed) products | |
| 15 | # - auth: how creators connect ('oauth' | 'api_key' | 'manual') | |
| 16 | # - fees: approximate marketplace fees | |
| 17 | # - signup_url: where the creator goes to make an account | |
| 18 | # - api_docs: link to that platform's API docs (for our reference) | |
| 19 | # - icon: single-letter symbol for the dashboard tile (no images) | |
| 20 | # | |
| 21 | # To add a new marketplace adapter: | |
| 22 | # 1. Add an entry below. | |
| 23 | # 2. Drop a module at MODS/WebSTLs/Marketplaces/<Slug>.pm implementing | |
| 24 | # push($self, $userinfo, $model_id) -- see Etsy.pm (planned) for shape. | |
| 25 | # 3. The marketplaces.cgi dashboard and the manual export page pick it | |
| 26 | # up automatically. | |
| 27 | #====================================================================== | |
| 28 | use strict; | |
| 29 | use warnings; | |
| 30 | use HTTP::Tiny; | |
| 31 | use JSON::PP; | |
| 32 | ||
| 33 | our @PLATFORMS = ( | |
| 34 | { | |
| 35 | slug => 'etsy', | |
| 36 | name => 'Etsy', | |
| 37 | tagline => 'Huge marketplace for both digital STL downloads and physical handmade goods.', | |
| 38 | status => 'live', | |
| 39 | sells => 'Digital STL downloads + physical (3D-printed) handmade goods.', | |
| 40 | sells_digital => 1, | |
| 41 | sells_physical => 1, | |
| 42 | auth => 'oauth', | |
| 43 | fees => '~6.5% transaction + $0.20 listing + payment processing', | |
| 44 | signup_url => 'https://www.etsy.com/sell', | |
| 45 | api_docs => 'https://developers.etsy.com/documentation/', | |
| 46 | icon => 'E', | |
| 47 | }, | |
| 48 | { | |
| 49 | slug => 'gumroad', | |
| 50 | name => 'Gumroad', | |
| 51 | tagline => 'Simplest digital storefront. Easiest API to integrate first.', | |
| 52 | status => 'live', | |
| 53 | sells => 'Digital downloads, courses, memberships.', | |
| 54 | sells_digital => 1, | |
| 55 | sells_physical => 0, | |
| 56 | auth => 'oauth', | |
| 57 | fees => '10% + payment processing', | |
| 58 | signup_url => 'https://gumroad.com', | |
| 59 | api_docs => 'https://help.gumroad.com/article/280-create-product-api', | |
| 60 | icon => 'G', | |
| 61 | }, | |
| 62 | { | |
| 63 | slug => 'mmf', | |
| 64 | name => 'MyMiniFactory', | |
| 65 | tagline => 'Where the tabletop miniature community lives.', | |
| 66 | status => 'live', | |
| 67 | sells => '3D models for miniatures, terrain, busts. Strong tabletop focus.', | |
| 68 | sells_digital => 1, | |
| 69 | sells_physical => 0, | |
| 70 | auth => 'oauth', | |
| 71 | fees => '~8% commission on sales', | |
| 72 | signup_url => 'https://www.myminifactory.com/store/start-selling', | |
| 73 | api_docs => 'https://www.myminifactory.com/developers', | |
| 74 | icon => 'M', | |
| 75 | }, | |
| 76 | { | |
| 77 | slug => 'thangs', | |
| 78 | name => 'Thangs', | |
| 79 | tagline => 'Search-first 3D marketplace. Strong creator monetization via Memberships.', | |
| 80 | status => 'live', | |
| 81 | sells => 'STL/OBJ printables, paid memberships, single-purchase downloads.', | |
| 82 | sells_digital => 1, | |
| 83 | sells_physical => 0, | |
| 84 | auth => 'oauth', | |
| 85 | fees => '0% on Memberships / Storefronts, ~12% on marketplace single sales', | |
| 86 | signup_url => 'https://thangs.com/designers', | |
| 87 | api_docs => 'https://thangs.com/developers', | |
| 88 | icon => 'Tg', | |
| 89 | }, | |
| 90 | { | |
| 91 | slug => 'cults3d', | |
| 92 | name => 'Cults3D', | |
| 93 | tagline => 'Large 3D-printable marketplace. France-based, global reach.', | |
| 94 | status => 'live', | |
| 95 | sells => 'STL/OBJ printables across categories.', | |
| 96 | sells_digital => 1, | |
| 97 | sells_physical => 0, | |
| 98 | auth => 'api_key', | |
| 99 | fees => '~15% commission', | |
| 100 | signup_url => 'https://cults3d.com/en/users/sign_up', | |
| 101 | api_docs => 'https://cults3d.com/en/api', | |
| 102 | icon => 'C', | |
| 103 | }, | |
| 104 | { | |
| 105 | slug => 'printables', | |
| 106 | name => 'Printables', | |
| 107 | tagline => 'Prusa-run community. Strong free-share culture, commercial section growing.', | |
| 108 | status => 'live', | |
| 109 | sells => 'STL files, mostly free, but paid is supported.', | |
| 110 | sells_digital => 1, | |
| 111 | sells_physical => 0, | |
| 112 | auth => 'api_key', | |
| 113 | fees => '0% (community-funded) / paid contests', | |
| 114 | signup_url => 'https://www.printables.com', | |
| 115 | api_docs => 'https://api.printables.com', | |
| 116 | icon => 'P', | |
| 117 | }, | |
| 118 | { | |
| 119 | slug => 'thingiverse', | |
| 120 | name => 'Thingiverse', | |
| 121 | tagline => 'MakerBot-run. Mostly free, limited commercial use.', | |
| 122 | status => 'live', | |
| 123 | sells => 'STL files, primarily community-shared.', | |
| 124 | sells_digital => 1, | |
| 125 | sells_physical => 0, | |
| 126 | auth => 'api_key', | |
| 127 | fees => 'n/a (mostly free files)', | |
| 128 | signup_url => 'https://www.thingiverse.com', | |
| 129 | api_docs => 'https://www.thingiverse.com/developers', | |
| 130 | icon => 'T', | |
| 131 | }, | |
| 132 | { | |
| 133 | slug => 'cgtrader', | |
| 134 | name => 'CGTrader', | |
| 135 | tagline => 'Pro 3D model marketplace. Stronger for game/film/render assets.', | |
| 136 | status => 'live', | |
| 137 | sells => '3D models (game assets, renders, some printables).', | |
| 138 | sells_digital => 1, | |
| 139 | sells_physical => 0, | |
| 140 | auth => 'api_key', | |
| 141 | fees => '~30% commission on standard sales', | |
| 142 | signup_url => 'https://www.cgtrader.com', | |
| 143 | api_docs => 'https://www.cgtrader.com/api/v1/docs', | |
| 144 | icon => 'X', | |
| 145 | }, | |
| 146 | { | |
| 147 | slug => 'patreon', | |
| 148 | name => 'Patreon', | |
| 149 | tagline => 'Membership tiers. Different shape -- subscription, not single sales.', | |
| 150 | status => 'live', | |
| 151 | sells => 'Tier-based file releases, fan support.', | |
| 152 | sells_digital => 1, | |
| 153 | sells_physical => 0, | |
| 154 | auth => 'oauth', | |
| 155 | fees => '8-12% + payment processing', | |
| 156 | signup_url => 'https://www.patreon.com/signup/creator', | |
| 157 | api_docs => 'https://docs.patreon.com', | |
| 158 | icon => 'P', | |
| 159 | }, | |
| 160 | { | |
| 161 | slug => 'payhip', | |
| 162 | name => 'Payhip', | |
| 163 | tagline => 'Digital storefront with no monthly fees. Indie-friendly.', | |
| 164 | status => 'live', | |
| 165 | sells => 'Digital downloads, courses, memberships.', | |
| 166 | sells_digital => 1, | |
| 167 | sells_physical => 0, | |
| 168 | auth => 'api_key', | |
| 169 | fees => '5% (free plan) / 0% (paid plan)', | |
| 170 | signup_url => 'https://payhip.com', | |
| 171 | api_docs => 'https://payhip.com/integration/api', | |
| 172 | icon => 'Pa', | |
| 173 | }, | |
| 174 | { | |
| 175 | slug => 'kofi', | |
| 176 | name => 'Ko-fi', | |
| 177 | tagline => 'Tips + small digital storefront for creators.', | |
| 178 | status => 'live', | |
| 179 | sells => 'Digital downloads, commissions, memberships, tips.', | |
| 180 | sells_digital => 1, | |
| 181 | sells_physical => 0, | |
| 182 | auth => 'api_key', | |
| 183 | fees => '0% (free) / 5% premium subscriptions', | |
| 184 | signup_url => 'https://ko-fi.com', | |
| 185 | api_docs => 'https://help.ko-fi.com/hc/en-us/articles/360004162298', | |
| 186 | icon => 'K', | |
| 187 | }, | |
| 188 | { | |
| 189 | slug => 'makerworld', | |
| 190 | name => 'MakerWorld', | |
| 191 | tagline => 'Bambu Lab\'s STL marketplace. Strong Bambu printer integration.', | |
| 192 | status => 'live', | |
| 193 | sells => 'STL/3MF files optimized for Bambu Studio and X-series printers.', | |
| 194 | sells_digital => 1, | |
| 195 | sells_physical => 0, | |
| 196 | auth => 'oauth', | |
| 197 | fees => '0% downloads / Boost program revenue share', | |
| 198 | signup_url => 'https://makerworld.com', | |
| 199 | api_docs => 'https://makerworld.com/en/developer', | |
| 200 | icon => 'Mw', | |
| 201 | }, | |
| 202 | { | |
| 203 | slug => 'amazon', | |
| 204 | name => 'Amazon (Handmade)', | |
| 205 | tagline => 'Massive physical-goods reach. Handmade program is the fit for 3D-printed items.', | |
| 206 | status => 'live', | |
| 207 | sells => 'Physical 3D-printed products (Handmade program). Digital files are not a fit here.', | |
| 208 | sells_digital => 0, | |
| 209 | sells_physical => 1, | |
| 210 | auth => 'oauth', | |
| 211 | fees => '15% Handmade referral + storage/fulfillment if FBA', | |
| 212 | signup_url => 'https://sell.amazon.com/programs/handmade', | |
| 213 | api_docs => 'https://developer-docs.amazon.com/sp-api/', | |
| 214 | icon => 'A', | |
| 215 | }, | |
| 216 | { | |
| 217 | slug => 'ebay', | |
| 218 | name => 'eBay', | |
| 219 | tagline => 'Global physical-goods marketplace. Good fit for printed objects + props.', | |
| 220 | status => 'live', | |
| 221 | sells => 'Physical 3D-printed products. Digital files allowed but uncommon.', | |
| 222 | sells_digital => 0, | |
| 223 | sells_physical => 1, | |
| 224 | auth => 'oauth', | |
| 225 | fees => '~12.55% final value fee on most categories', | |
| 226 | signup_url => 'https://www.ebay.com/sl/sell', | |
| 227 | api_docs => 'https://developer.ebay.com/api-docs/static/sell-landing.html', | |
| 228 | icon => 'eB', | |
| 229 | }, | |
| 230 | { | |
| 231 | slug => 'shopify', | |
| 232 | name => 'Shopify', | |
| 233 | tagline => 'Hosted storefront for physical goods. Push physical products here from WebSTLs.', | |
| 234 | status => 'live', | |
| 235 | sells => 'Physical 3D-printed products on the seller\'s own Shopify storefront.', | |
| 236 | sells_digital => 0, | |
| 237 | sells_physical => 1, | |
| 238 | auth => 'oauth', | |
| 239 | fees => 'Plan fee + 2.4-2.9% payment processing', | |
| 240 | signup_url => 'https://www.shopify.com/sell', | |
| 241 | api_docs => 'https://shopify.dev/docs/api', | |
| 242 | icon => 'S', | |
| 243 | }, | |
| 244 | { | |
| 245 | slug => 'walmart', | |
| 246 | name => 'Walmart', | |
| 247 | tagline => 'US-only physical-goods marketplace.', | |
| 248 | status => 'live', | |
| 249 | sells => 'Physical 3D-printed products, US-based sellers.', | |
| 250 | sells_digital => 0, | |
| 251 | sells_physical => 1, | |
| 252 | auth => 'api_key', | |
| 253 | fees => '6-15% referral by category', | |
| 254 | signup_url => 'https://marketplace.walmart.com', | |
| 255 | api_docs => 'https://developer.walmart.com/home/us-mp', | |
| 256 | icon => 'W', | |
| 257 | }, | |
| 258 | ); | |
| 259 | ||
| 260 | #---------------------------------------------------------------------- | |
| 261 | # Per-platform credential field schemas. Each entry lists the fields | |
| 262 | # the connect modal asks for. Two main shapes: | |
| 263 | # - api_key platforms: {handle, key} -- paste-in API key + the shop | |
| 264 | # handle on that platform | |
| 265 | # - oauth platforms: {handle, key} -- platforms vary but every one | |
| 266 | # accepts a Personal Access Token | |
| 267 | # paste-in path as fallback for | |
| 268 | # installs that haven't completed | |
| 269 | # dev-account approval yet. | |
| 270 | # Each field is { name, label, hint, type, required, secret }. | |
| 271 | # - name: payload key + DB field | |
| 272 | # - label: form label shown to the creator | |
| 273 | # - hint: one-line helper text under the input | |
| 274 | # - type: 'text' | 'password' | 'url' | |
| 275 | # - secret: 1 if the value should be masked in the UI after save | |
| 276 | #---------------------------------------------------------------------- | |
| 277 | our %CRED_FIELDS; | |
| 278 | %CRED_FIELDS = ( | |
| 279 | etsy => [ | |
| 280 | { name=>'account_handle', label=>'Etsy shop name', hint=>'The slug after etsy.com/shop/ — e.g. "3DShawn".', type=>'text', required=>1, secret=>0 }, | |
| 281 | { name=>'shop_id', label=>'Etsy shop ID', hint=>'Numeric ID. Find at <a href="https://www.etsy.com/your/shops/me/onboarding/details" target="_blank">your shop details</a> page.', type=>'text', required=>0, secret=>0 }, | |
| 282 | { name=>'access_token', label=>'OAuth access token', hint=>'Generated at <a href="https://developers.etsy.com" target="_blank">developers.etsy.com</a> → My Apps → OAuth.', type=>'password', required=>1, secret=>1 }, | |
| 283 | ], | |
| 284 | gumroad => [ | |
| 285 | { name=>'account_handle', label=>'Gumroad username', hint=>'Your gumroad.com/your-name handle.', type=>'text', required=>1, secret=>0 }, | |
| 286 | { name=>'access_token', label=>'Gumroad access token', hint=>'Generate at <a href="https://gumroad.com/settings/advanced" target="_blank">Settings → Advanced → Applications</a>.', type=>'password', required=>1, secret=>1 }, | |
| 287 | ], | |
| 288 | mmf => [ | |
| 289 | { name=>'account_handle', label=>'MyMiniFactory username', hint=>'Your shop URL slug.', type=>'text', required=>1, secret=>0 }, | |
| 290 | { name=>'access_token', label=>'API access token', hint=>'Request from <a href="https://www.myminifactory.com/developers" target="_blank">MMF developers portal</a>.', type=>'password', required=>1, secret=>1 }, | |
| 291 | ], | |
| 292 | thangs => [ | |
| 293 | # Handle stays because it's useful for display ("Connected as | |
| 294 | # 3DShawn"), linking to the profile, and de-anonymizing the | |
| 295 | # tile state. The API token field is intentionally absent -- | |
| 296 | # Thangs has no public creator-upload API and we couldn't pass | |
| 297 | # their Cloudflare bot filter to scrape, so a token field would | |
| 298 | # just be theater. If Thangs ever ships a real creator API, | |
| 299 | # re-add the field here. | |
| 300 | { name=>'account_handle', label=>'Thangs designer handle', | |
| 301 | hint=>'Go to your Thangs profile and look at the URL — e.g. <code>thangs.com/designer/<strong style="color:#a78bfa">3DShawn</strong></code> means your handle is <code>3DShawn</code>. <strong>Case-sensitive</strong>. Not your display name.', | |
| 302 | type=>'text', required=>1, secret=>0 }, | |
| 303 | ], | |
| 304 | cults3d => [ | |
| 305 | { name=>'account_handle', label=>'Cults3D URL handle', | |
| 306 | hint=>'Go to your Cults3D profile, then look at the URL in your browser and extract your username from it — e.g. <code>cults3d.com/en/users/<strong style="color:#a78bfa">3D-Shawn</strong></code> means your handle is <code>3D-Shawn</code>. <strong>Case-sensitive.</strong> Not your display name (<code>@3D-Shawn</code>) or email.', | |
| 307 | type=>'text', required=>1, secret=>0 }, | |
| 308 | { name=>'access_token', label=>'Cults3D API token', | |
| 309 | hint=>'Used to push and pull your listings from Cults3D. <strong>To generate one:</strong> head to <a href="https://cults3d.com/en/api/keys" target="_blank" style="color:#a78bfa">cults3d.com/en/api/keys</a> → click <strong>ADD</strong> → name it (e.g. "WebSTLs") → check <strong>Read</strong> (and <strong>Write</strong> if you want push too) → <strong>Create API Key</strong>. Copy the displayed code immediately — Cults3D only shows it once.', | |
| 310 | type=>'password', required=>0, secret=>1 }, | |
| 311 | ], | |
| 312 | printables => [ | |
| 313 | { name=>'account_handle', label=>'Printables username', hint=>'Your printables.com/<username>.', type=>'text', required=>1, secret=>0 }, | |
| 314 | { name=>'access_token', label=>'Printables API key', hint=>'Request via the developer portal at api.printables.com.', type=>'password', required=>1, secret=>1 }, | |
| 315 | ], | |
| 316 | thingiverse => [ | |
| 317 | { name=>'account_handle', label=>'Thingiverse username', hint=>'Your thingiverse.com/<username>.', type=>'text', required=>1, secret=>0 }, | |
| 318 | { name=>'access_token', label=>'Thingiverse OAuth token',hint=>'Generated at <a href="https://www.thingiverse.com/developers" target="_blank">thingiverse.com/developers</a>.', type=>'password', required=>1, secret=>1 }, | |
| 319 | ], | |
| 320 | cgtrader => [ | |
| 321 | { name=>'account_handle', label=>'CGTrader designer slug', hint=>'Your cgtrader.com/designers/<slug>.', type=>'text', required=>1, secret=>0 }, | |
| 322 | { name=>'access_token', label=>'CGTrader API key', hint=>'Generate at CGTrader designer settings.', type=>'password', required=>1, secret=>1 }, | |
| 323 | ], | |
| 324 | patreon => [ | |
| 325 | { name=>'account_handle', label=>'Patreon campaign slug', hint=>'Your patreon.com/<slug>.', type=>'text', required=>1, secret=>0 }, | |
| 326 | { name=>'access_token', label=>'Creator access token', hint=>'Generate at <a href="https://www.patreon.com/portal/registration/register-clients" target="_blank">patreon.com/portal/registration</a>.', type=>'password', required=>1, secret=>1 }, | |
| 327 | ], | |
| 328 | payhip => [ | |
| 329 | { name=>'account_handle', label=>'Payhip store handle', hint=>'Your payhip.com/<handle>.', type=>'text', required=>1, secret=>0 }, | |
| 330 | { name=>'access_token', label=>'Payhip API key', hint=>'Generate at <a href="https://payhip.com/account/api" target="_blank">payhip.com/account/api</a>.', type=>'password', required=>1, secret=>1 }, | |
| 331 | ], | |
| 332 | kofi => [ | |
| 333 | { name=>'account_handle', label=>'Ko-fi username', hint=>'Your ko-fi.com/<username>.', type=>'text', required=>1, secret=>0 }, | |
| 334 | { name=>'access_token', label=>'Ko-fi API key', hint=>'Generate at Ko-fi → Settings → API.', type=>'password', required=>1, secret=>1 }, | |
| 335 | ], | |
| 336 | makerworld => [ | |
| 337 | { name=>'account_handle', label=>'MakerWorld username', hint=>'Your makerworld.com/<username>.', type=>'text', required=>1, secret=>0 }, | |
| 338 | { name=>'access_token', label=>'OAuth access token', hint=>'Generate via Bambu/MakerWorld developer console.', type=>'password', required=>1, secret=>1 }, | |
| 339 | ], | |
| 340 | amazon => [ | |
| 341 | { name=>'account_handle', label=>'Amazon seller ID', hint=>'Your Seller Central merchant token.', type=>'text', required=>1, secret=>0 }, | |
| 342 | { name=>'access_token', label=>'SP-API LWA refresh token',hint=>'Generated via Amazon SP-API LWA after Handmade approval.', type=>'password', required=>1, secret=>1 }, | |
| 343 | ], | |
| 344 | ebay => [ | |
| 345 | { name=>'account_handle', label=>'eBay seller username', hint=>'Your eBay username.', type=>'text', required=>1, secret=>0 }, | |
| 346 | { name=>'access_token', label=>'eBay user access token', hint=>'OAuth user token, scope=sell.inventory.', type=>'password', required=>1, secret=>1 }, | |
| 347 | ], | |
| 348 | shopify => [ | |
| 349 | { name=>'account_handle', label=>'Shopify shop domain', hint=>'e.g. "your-shop.myshopify.com".', type=>'text', required=>1, secret=>0 }, | |
| 350 | { name=>'access_token', label=>'Admin API access token', hint=>'Generated in Shopify Admin → Apps → Develop apps → Admin API access.', type=>'password', required=>1, secret=>1 }, | |
| 351 | ], | |
| 352 | walmart => [ | |
| 353 | { name=>'account_handle', label=>'Walmart seller ID', hint=>'Your Walmart Marketplace seller ID.', type=>'text', required=>1, secret=>0 }, | |
| 354 | { name=>'access_token', label=>'Walmart API key', hint=>'From Marketplace developer portal.', type=>'password', required=>1, secret=>1 }, | |
| 355 | ], | |
| 356 | ); | |
| 357 | ||
| 358 | sub new { return bless({}, shift) } | |
| 359 | ||
| 360 | # Return the full platform list (shallow copies). | |
| 361 | sub all { return [ map { { %$_ } } @PLATFORMS ]; } | |
| 362 | ||
| 363 | # Filter helpers used by upload.cgi / marketplaces.cgi to surface only | |
| 364 | # the platforms relevant to a given product kind. | |
| 365 | sub digital_only { | |
| 366 | my $self = shift; | |
| 367 | return [ grep { $_->{sells_digital} } map { { %$_ } } @PLATFORMS ]; | |
| 368 | } | |
| 369 | sub physical_only { | |
| 370 | my $self = shift; | |
| 371 | return [ grep { $_->{sells_physical} } map { { %$_ } } @PLATFORMS ]; | |
| 372 | } | |
| 373 | # Convenience for the picker UI: returns platforms that accept the | |
| 374 | # given product kind ('digital', 'physical', 'both'). | |
| 375 | sub for_kind { | |
| 376 | my ($self, $kind) = @_; | |
| 377 | $kind ||= 'digital'; | |
| 378 | if ($kind eq 'physical') { | |
| 379 | return $self->physical_only; | |
| 380 | } elsif ($kind eq 'both') { | |
| 381 | return [ map { { %$_ } } @PLATFORMS ]; # both = every platform that does either | |
| 382 | } | |
| 383 | return $self->digital_only; | |
| 384 | } | |
| 385 | ||
| 386 | # Find a single platform by slug. | |
| 387 | sub by_slug { | |
| 388 | my ($self, $slug) = @_; | |
| 389 | return undef unless $slug; | |
| 390 | $slug = lc($slug); | |
| 391 | $slug =~ s/[^a-z0-9]//g; | |
| 392 | foreach my $p (@PLATFORMS) { | |
| 393 | return { %$p } if $p->{slug} eq $slug; | |
| 394 | } | |
| 395 | return undef; | |
| 396 | } | |
| 397 | ||
| 398 | # Return the credential-field spec for a platform. Used by the connect | |
| 399 | # modal to render the right form. Returns an arrayref or undef. | |
| 400 | sub cred_fields { | |
| 401 | my ($self, $slug) = @_; | |
| 402 | return undef unless $slug; | |
| 403 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; | |
| 404 | return $CRED_FIELDS{$slug}; | |
| 405 | } | |
| 406 | ||
| 407 | # Which platforms have a model-import adapter, and HOW they import. | |
| 408 | # 'auto' = ImportAdapters.pm has a scraper / API client that can | |
| 409 | # pull the seller's list end-to-end (e.g. Cults3D's public | |
| 410 | # profile scrape). | |
| 411 | # 'manual' = no end-to-end fetch is possible (platform is behind | |
| 412 | # Cloudflare / JS-challenge bot protection, no public API), | |
| 413 | # so the UI shows a paste-URLs textarea and the dispatcher | |
| 414 | # creates drafts from the user-supplied list. | |
| 415 | # The tile UI branches on this to render the right button + modal. | |
| 416 | our %IMPORT_SUPPORTED = ( | |
| 417 | cults3d => 'auto', | |
| 418 | thangs => 'manual', | |
| 419 | ); | |
| 420 | sub import_supported { | |
| 421 | my ($self, $slug) = @_; | |
| 422 | return 0 unless $slug; | |
| 423 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; | |
| 424 | return $IMPORT_SUPPORTED{$slug} ? 1 : 0; | |
| 425 | } | |
| 426 | sub import_mode { | |
| 427 | my ($self, $slug) = @_; | |
| 428 | return '' unless $slug; | |
| 429 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; | |
| 430 | return $IMPORT_SUPPORTED{$slug} || ''; | |
| 431 | } | |
| 432 | ||
| 433 | #---------------------------------------------------------------------- | |
| 434 | # Connection state (marketplace_accounts table) | |
| 435 | #---------------------------------------------------------------------- | |
| 436 | ||
| 437 | # Returns 1 if marketplace_accounts table exists. Guards the rest of | |
| 438 | # the helpers against installs that haven't run the migration yet. | |
| 439 | sub _has_accounts_table { | |
| 440 | my ($self, $db, $dbh, $DB) = @_; | |
| 441 | my $r = $db->db_readwrite($dbh, qq~ | |
| 442 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 443 | WHERE table_schema='$DB' AND table_name='marketplace_accounts' | |
| 444 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 445 | return ($r && $r->{n}) ? 1 : 0; | |
| 446 | } | |
| 447 | ||
| 448 | # Fetch the marketplace_accounts row for one user+platform. | |
| 449 | # Returns hashref or undef (never dies, even with missing table). | |
| 450 | sub account_for { | |
| 451 | my ($self, $db, $dbh, $DB, $uid, $slug) = @_; | |
| 452 | return undef unless $uid && $slug; | |
| 453 | $uid =~ s/[^0-9]//g; return undef unless length $uid; | |
| 454 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; return undef unless length $slug; | |
| 455 | return undef unless $self->_has_accounts_table($db, $dbh, $DB); | |
| 456 | ||
| 457 | my $r = $db->db_readwrite($dbh, qq~ | |
| 458 | SELECT id, user_id, platform, status, access_token, refresh_token, | |
| 459 | token_expires_at, account_handle, metadata, last_error, | |
| 460 | connected_at, last_used_at, created_at, updated_at | |
| 461 | FROM `${DB}`.marketplace_accounts | |
| 462 | WHERE user_id='$uid' AND platform='$slug' | |
| 463 | LIMIT 1 | |
| 464 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 465 | return ($r && $r->{id}) ? $r : undef; | |
| 466 | } | |
| 467 | ||
| 468 | # Return { slug => row } for every marketplace_accounts row this user | |
| 469 | # owns. Useful for marketplaces.cgi to look up all states in one pass | |
| 470 | # without one SELECT per tile. | |
| 471 | sub accounts_by_slug { | |
| 472 | my ($self, $db, $dbh, $DB, $uid) = @_; | |
| 473 | my %out; | |
| 474 | return \%out unless $uid; | |
| 475 | $uid =~ s/[^0-9]//g; return \%out unless length $uid; | |
| 476 | return \%out unless $self->_has_accounts_table($db, $dbh, $DB); | |
| 477 | ||
| 478 | # access_token is included so the manage-modal can flag it as STORED | |
| 479 | # in the UI. Marketplaces.cgi NEVER echoes the token value back to | |
| 480 | # the rendered HTML (secret fields render with value='' and only | |
| 481 | # the is_saved boolean is exposed) -- the column is selected purely | |
| 482 | # to tell "is something stored here?" apart from "blank". | |
| 483 | my @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 484 | SELECT id, platform, status, account_handle, access_token, last_error, | |
| 485 | last_used_at, connected_at, metadata | |
| 486 | FROM `${DB}`.marketplace_accounts | |
| 487 | WHERE user_id='$uid' | |
| 488 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 489 | foreach my $r (@rows) { | |
| 490 | $out{ $r->{platform} } = $r; | |
| 491 | } | |
| 492 | return \%out; | |
| 493 | } | |
| 494 | ||
| 495 | # Save credentials for a (user, platform) pair. Upserts so re-connecting | |
| 496 | # overwrites old tokens cleanly. $fields is a hashref keyed by the field | |
| 497 | # `name`s from CRED_FIELDS. Returns { ok=>1 } on success or { ok=>0, | |
| 498 | # err=>'...' } on failure. | |
| 499 | sub connect_account { | |
| 500 | my ($self, $db, $dbh, $DB, $uid, $slug, $fields) = @_; | |
| 501 | return { ok => 0, err => 'no_user' } unless $uid; | |
| 502 | return { ok => 0, err => 'no_platform' } unless $slug; | |
| 503 | $uid =~ s/[^0-9]//g; | |
| 504 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; | |
| 505 | return { ok => 0, err => 'bad_input' } unless length($uid) && length($slug); | |
| 506 | return { ok => 0, err => 'no_table' } unless $self->_has_accounts_table($db, $dbh, $DB); | |
| 507 | ||
| 508 | my $spec = $CRED_FIELDS{$slug}; | |
| 509 | return { ok => 0, err => 'unknown_platform' } unless $spec; | |
| 510 | ||
| 511 | # On Manage (re-save), the form intentionally sends a blank for | |
| 512 | # any secret field the user didn't retype -- the placeholder told | |
| 513 | # them "leave blank to keep". So we look up the existing row first | |
| 514 | # and back-fill blank secret submissions with the stored value | |
| 515 | # before validation, so a user editing only their URL handle | |
| 516 | # doesn't accidentally clobber a still-good API token. | |
| 517 | my $existing = $self->account_for($db, $dbh, $DB, $uid, $slug); | |
| 518 | if ($existing) { | |
| 519 | foreach my $f (@$spec) { | |
| 520 | next unless $f->{secret}; | |
| 521 | my $submitted = defined $fields->{ $f->{name} } ? $fields->{ $f->{name} } : ''; | |
| 522 | $submitted =~ s/^\s+|\s+$//g; | |
| 523 | if (!length $submitted && length($existing->{ $f->{name} } || '')) { | |
| 524 | $fields->{ $f->{name} } = $existing->{ $f->{name} }; | |
| 525 | } | |
| 526 | } | |
| 527 | } | |
| 528 | ||
| 529 | # Validate required fields and pull the canonical ones. | |
| 530 | my %got; | |
| 531 | foreach my $f (@$spec) { | |
| 532 | my $v = defined $fields->{ $f->{name} } ? $fields->{ $f->{name} } : ''; | |
| 533 | $v =~ s/^\s+|\s+$//g; | |
| 534 | if ($f->{required} && !length $v) { | |
| 535 | return { ok => 0, err => 'missing:' . $f->{name} }; | |
| 536 | } | |
| 537 | $got{ $f->{name} } = $v; | |
| 538 | } | |
| 539 | ||
| 540 | my $handle = $got{account_handle} || ''; | |
| 541 | my $token = $got{access_token} || ''; | |
| 542 | # Any extras (shop_id, etc.) go in the metadata JSON blob so we | |
| 543 | # don't have to bolt a new column per platform onto the schema. | |
| 544 | my %extras; | |
| 545 | foreach my $f (@$spec) { | |
| 546 | next if $f->{name} eq 'account_handle' || $f->{name} eq 'access_token'; | |
| 547 | $extras{ $f->{name} } = $got{ $f->{name} } if defined $got{ $f->{name} }; | |
| 548 | } | |
| 549 | my $meta = ''; | |
| 550 | if (scalar keys %extras) { | |
| 551 | # Tiny JSON encoder -- one level deep, strings only. | |
| 552 | my @kv; | |
| 553 | foreach my $k (sort keys %extras) { | |
| 554 | my $v = $extras{$k}; | |
| 555 | $v =~ s/\\/\\\\/g; $v =~ s/"/\\"/g; $v =~ s/[\r\n\t]/ /g; | |
| 556 | push @kv, '"' . $k . '":"' . $v . '"'; | |
| 557 | } | |
| 558 | $meta = '{' . join(',', @kv) . '}'; | |
| 559 | } | |
| 560 | ||
| 561 | # Escape values for SQL. | |
| 562 | my $h = $handle; $h =~ s/'/''/g; $h = substr($h, 0, 191); | |
| 563 | my $t = $token; $t =~ s/'/''/g; $t = substr($t, 0, 2000); | |
| 564 | my $m = $meta; $m =~ s/'/''/g; $m = substr($m, 0, 4000); | |
| 565 | my $h_sql = length $h ? "'$h'" : 'NULL'; | |
| 566 | my $t_sql = length $t ? "'$t'" : 'NULL'; | |
| 567 | my $m_sql = length $m ? "'$m'" : 'NULL'; | |
| 568 | ||
| 569 | # Upsert: try INSERT, fall through to UPDATE on duplicate | |
| 570 | # (uq_mp_user_platform unique key on user_id+platform). $existing | |
| 571 | # was already loaded above for the secret-field preservation pass. | |
| 572 | if ($existing && $existing->{id}) { | |
| 573 | my $id = $existing->{id}; $id =~ s/[^0-9]//g; | |
| 574 | $db->db_readwrite($dbh, qq~ | |
| 575 | UPDATE `${DB}`.marketplace_accounts | |
| 576 | SET status='connected', | |
| 577 | access_token=$t_sql, | |
| 578 | account_handle=$h_sql, | |
| 579 | metadata=$m_sql, | |
| 580 | last_error=NULL, | |
| 581 | connected_at=COALESCE(connected_at, NOW()), | |
| 582 | last_used_at=NOW() | |
| 583 | WHERE id='$id' | |
| 584 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 585 | } else { | |
| 586 | $db->db_readwrite($dbh, qq~ | |
| 587 | INSERT INTO `${DB}`.marketplace_accounts | |
| 588 | SET user_id='$uid', | |
| 589 | platform='$slug', | |
| 590 | status='connected', | |
| 591 | access_token=$t_sql, | |
| 592 | account_handle=$h_sql, | |
| 593 | metadata=$m_sql, | |
| 594 | connected_at=NOW(), | |
| 595 | created_at=NOW() | |
| 596 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 597 | } | |
| 598 | return { ok => 1 }; | |
| 599 | } | |
| 600 | ||
| 601 | # Sever a user's connection to one platform. Clears tokens + sets | |
| 602 | # status='disconnected'. Returns { ok=>1 } either way (idempotent). | |
| 603 | sub disconnect_account { | |
| 604 | my ($self, $db, $dbh, $DB, $uid, $slug) = @_; | |
| 605 | return { ok => 0, err => 'no_user' } unless $uid; | |
| 606 | return { ok => 0, err => 'no_platform' } unless $slug; | |
| 607 | $uid =~ s/[^0-9]//g; | |
| 608 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; | |
| 609 | return { ok => 0, err => 'bad_input' } unless length($uid) && length($slug); | |
| 610 | return { ok => 0, err => 'no_table' } unless $self->_has_accounts_table($db, $dbh, $DB); | |
| 611 | ||
| 612 | $db->db_readwrite($dbh, qq~ | |
| 613 | UPDATE `${DB}`.marketplace_accounts | |
| 614 | SET status='disconnected', | |
| 615 | access_token=NULL, | |
| 616 | refresh_token=NULL, | |
| 617 | token_expires_at=NULL, | |
| 618 | last_error=NULL | |
| 619 | WHERE user_id='$uid' AND platform='$slug' | |
| 620 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 621 | return { ok => 1 }; | |
| 622 | } | |
| 623 | ||
| 624 | # Lightweight sanity-check on a stored connection. For v1 this just | |
| 625 | # confirms the account row exists and has non-empty credentials. Once | |
| 626 | # per-platform push adapters ship, this method will dispatch into the | |
| 627 | # adapter's verify() which makes a real API ping (e.g. Gumroad | |
| 628 | # /v2/user) to confirm the token still works. | |
| 629 | sub verify_account { | |
| 630 | my ($self, $db, $dbh, $DB, $uid, $slug) = @_; | |
| 631 | my $acc = $self->account_for($db, $dbh, $DB, $uid, $slug); | |
| 632 | return { ok => 0, err => 'not_connected' } | |
| 633 | unless $acc && $acc->{status} eq 'connected'; | |
| 634 | return { ok => 0, err => 'missing_token' } | |
| 635 | unless length($acc->{access_token} || ''); | |
| 636 | return { ok => 1, account_handle => $acc->{account_handle} }; | |
| 637 | } | |
| 638 | ||
| 639 | # Queue a model for push to a platform. Writes a marketplace_pushes | |
| 640 | # row in status='pending'; the _marketplace_push_worker.pl daemon | |
| 641 | # (future) picks it up and dispatches to the adapter. Per-platform | |
| 642 | # real API calls aren't wired yet -- this is the connect framework | |
| 643 | # this turn. Returns { ok=>1, push_id=>N } or error. | |
| 644 | sub queue_push { | |
| 645 | my ($self, $db, $dbh, $DB, $uid, $slug, $model_id) = @_; | |
| 646 | return { ok => 0, err => 'no_user' } unless $uid; | |
| 647 | return { ok => 0, err => 'no_platform' } unless $slug; | |
| 648 | return { ok => 0, err => 'no_model' } unless $model_id; | |
| 649 | $uid =~ s/[^0-9]//g; | |
| 650 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; | |
| 651 | $model_id =~ s/[^0-9]//g; | |
| 652 | return { ok => 0, err => 'bad_input' } unless length($uid) && length($slug) && length($model_id); | |
| 653 | ||
| 654 | # marketplace_pushes table is a separate schema dependency. Skip | |
| 655 | # gracefully if the table isn't there yet. | |
| 656 | my $r = $db->db_readwrite($dbh, qq~ | |
| 657 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 658 | WHERE table_schema='$DB' AND table_name='marketplace_pushes' | |
| 659 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 660 | return { ok => 0, err => 'no_table' } unless $r && $r->{n}; | |
| 661 | ||
| 662 | # Verify the user is connected to this platform before queuing. | |
| 663 | my $v = $self->verify_account($db, $dbh, $DB, $uid, $slug); | |
| 664 | return $v unless $v->{ok}; | |
| 665 | ||
| 666 | $db->db_readwrite($dbh, qq~ | |
| 667 | INSERT INTO `${DB}`.marketplace_pushes | |
| 668 | SET user_id='$uid', | |
| 669 | platform='$slug', | |
| 670 | model_id='$model_id', | |
| 671 | status='pending', | |
| 672 | created_at=NOW() | |
| 673 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 674 | return { ok => 1 }; | |
| 675 | } | |
| 676 | ||
| 677 | # ---- Push worker support -------------------------------------------- | |
| 678 | # _marketplace_push_worker.pl calls pending_pushes() to grab a batch | |
| 679 | # of rows in status='pending', then run_push() one at a time to | |
| 680 | # dispatch. Both methods guard against the marketplace_pushes table | |
| 681 | # not existing yet (pre-migration installs). | |
| 682 | ||
| 683 | sub pending_pushes { | |
| 684 | my ($self, $db, $dbh, $DB, $limit) = @_; | |
| 685 | $limit ||= 25; | |
| 686 | $limit =~ s/[^0-9]//g; | |
| 687 | $limit = 25 unless $limit; | |
| 688 | ||
| 689 | my $r = $db->db_readwrite($dbh, qq~ | |
| 690 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 691 | WHERE table_schema='$DB' AND table_name='marketplace_pushes' | |
| 692 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 693 | return () unless $r && $r->{n}; | |
| 694 | ||
| 695 | return $db->db_readwrite_multiple($dbh, qq~ | |
| 696 | SELECT id, user_id, model_id, platform | |
| 697 | FROM `${DB}`.marketplace_pushes | |
| 698 | WHERE status='pending' | |
| 699 | ORDER BY id ASC | |
| 700 | LIMIT $limit | |
| 701 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 702 | } | |
| 703 | ||
| 704 | # Dispatch a single push row to its platform's adapter. The adapter | |
| 705 | # returns either { ok=>1, remote_listing_id, remote_url } or | |
| 706 | # { ok=>0, error }. We update the row accordingly. Either way we | |
| 707 | # never throw; the worker keeps going. | |
| 708 | sub run_push { | |
| 709 | my ($self, $db, $dbh, $DB, $row) = @_; | |
| 710 | return { ok => 0, error => 'no row' } unless $row && $row->{id}; | |
| 711 | ||
| 712 | my $pid = $row->{id}; $pid =~ s/[^0-9]//g; | |
| 713 | my $uid = $row->{user_id}; $uid =~ s/[^0-9]//g; | |
| 714 | my $mid = $row->{model_id}; $mid =~ s/[^0-9]//g; | |
| 715 | my $slug = lc($row->{platform} || ''); | |
| 716 | $slug =~ s/[^a-z0-9]//g; | |
| 717 | return { ok => 0, error => 'bad input' } unless $pid && $uid && $mid && $slug; | |
| 718 | ||
| 719 | my $acct = $self->account_for($db, $dbh, $DB, $uid, $slug); | |
| 720 | unless ($acct && ($acct->{status} || '') eq 'connected') { | |
| 721 | return $self->_finish_push($db, $dbh, $DB, $pid, { | |
| 722 | ok => 0, | |
| 723 | error => "Not connected to $slug. Reconnect on /marketplaces.cgi and re-queue the push.", | |
| 724 | }); | |
| 725 | } | |
| 726 | ||
| 727 | # Per-platform dispatch. Each method here is intentionally a stub | |
| 728 | # that returns { ok => 0, error => 'register OAuth at ...' } -- | |
| 729 | # the framework around it (queueing, retries, status display, the | |
| 730 | # worker loop) is real. Wiring a platform live is a matter of | |
| 731 | # replacing one of these bodies with real HTTP calls and the rest | |
| 732 | # of the system already moves the model the rest of the way. | |
| 733 | my $model = $self->_model_row($db, $dbh, $DB, $mid); | |
| 734 | unless ($model) { | |
| 735 | return $self->_finish_push($db, $dbh, $DB, $pid, { | |
| 736 | ok => 0, | |
| 737 | error => 'Model not found.', | |
| 738 | }); | |
| 739 | } | |
| 740 | ||
| 741 | my %dispatch = ( | |
| 742 | cults3d => \&_push_cults3d, | |
| 743 | gumroad => \&_push_gumroad, | |
| 744 | myminifactory => \&_push_myminifactory, | |
| 745 | patreon => \&_push_patreon, | |
| 746 | printables => \&_push_printables, | |
| 747 | thangs => \&_push_thangs, | |
| 748 | etsy => \&_push_etsy, | |
| 749 | ); | |
| 750 | my $fn = $dispatch{$slug}; | |
| 751 | unless ($fn) { | |
| 752 | return $self->_finish_push($db, $dbh, $DB, $pid, { | |
| 753 | ok => 0, | |
| 754 | error => "No push adapter for platform '$slug'.", | |
| 755 | }); | |
| 756 | } | |
| 757 | my $result = $fn->($self, $acct, $model); | |
| 758 | return $self->_finish_push($db, $dbh, $DB, $pid, $result); | |
| 759 | } | |
| 760 | ||
| 761 | # Helper: mark the row terminal with whatever the adapter returned. | |
| 762 | sub _finish_push { | |
| 763 | my ($self, $db, $dbh, $DB, $pid, $result) = @_; | |
| 764 | $result ||= { ok => 0, error => 'no result' }; | |
| 765 | my $status = $result->{ok} ? 'success' : 'failed'; | |
| 766 | my $remote_id = defined $result->{remote_listing_id} ? $result->{remote_listing_id} : ''; | |
| 767 | my $remote_url = defined $result->{remote_url} ? $result->{remote_url} : ''; | |
| 768 | my $err = defined $result->{error} ? $result->{error} : ''; | |
| 769 | for ($remote_id, $remote_url, $err) { s/'/''/g; } | |
| 770 | $remote_id = substr($remote_id, 0, 191); | |
| 771 | $remote_url = substr($remote_url, 0, 500); | |
| 772 | $remote_id = length($remote_id) ? "'$remote_id'" : 'NULL'; | |
| 773 | $remote_url = length($remote_url) ? "'$remote_url'" : 'NULL'; | |
| 774 | $err = length($err) ? "'$err'" : 'NULL'; | |
| 775 | ||
| 776 | $db->db_readwrite($dbh, qq~ | |
| 777 | UPDATE `${DB}`.marketplace_pushes | |
| 778 | SET status='$status', | |
| 779 | remote_listing_id=$remote_id, | |
| 780 | remote_url=$remote_url, | |
| 781 | error_message=$err | |
| 782 | WHERE id='$pid' | |
| 783 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 784 | return $result; | |
| 785 | } | |
| 786 | ||
| 787 | # Minimal model lookup -- adapters need title, description, price, etc. | |
| 788 | # Also resolves a public hero-image URL from primary_image_id so | |
| 789 | # adapters that take image-by-URL can attach one to the listing. | |
| 790 | sub _model_row { | |
| 791 | my ($self, $db, $dbh, $DB, $mid) = @_; | |
| 792 | return undef unless $mid; | |
| 793 | my $r = $db->db_readwrite($dbh, qq~ | |
| 794 | SELECT id, user_id, title, slug, description, | |
| 795 | base_price_cents, currency, product_kind, status, | |
| 796 | category, license_type, primary_image_id, | |
| 797 | tagline, highlights_json | |
| 798 | FROM `${DB}`.models WHERE id='$mid' LIMIT 1 | |
| 799 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 800 | return undef unless $r && $r->{id}; | |
| 801 | ||
| 802 | # Resolve the public image URL via /img.cgi (handles signed access). | |
| 803 | $r->{hero_image_url} = $r->{primary_image_id} | |
| 804 | ? 'https://webstls.com/img.cgi?id=' . $r->{primary_image_id} | |
| 805 | : ''; | |
| 806 | ||
| 807 | # Expose the dollars amount adapters need without forcing each one | |
| 808 | # to do the math. | |
| 809 | $r->{price_dollars} = sprintf('%.2f', ($r->{base_price_cents} || 0) / 100); | |
| 810 | $r->{price_cents} = $r->{base_price_cents} || 0; # back-compat alias | |
| 811 | return $r; | |
| 812 | } | |
| 813 | ||
| 814 | #====================================================================== | |
| 815 | # Shared HTTP plumbing for push adapters | |
| 816 | #====================================================================== | |
| 817 | # JSON request/response with Bearer or custom Authorization. Every | |
| 818 | # push adapter routes through this so error formatting + token | |
| 819 | # handling stays consistent. Uses HTTP::Tiny + JSON::PP -- both core | |
| 820 | # Perl, no CPAN deps. | |
| 821 | # | |
| 822 | # Returns one of: | |
| 823 | # { ok => 1, status => 200, data => { ...parsed json... } } | |
| 824 | # { ok => 0, error => "human-readable message", status => N } | |
| 825 | sub _http_json { | |
| 826 | my ($self, $method, $url, $headers, $body_ref) = @_; | |
| 827 | $headers ||= {}; | |
| 828 | my $ua = HTTP::Tiny->new( | |
| 829 | timeout => 30, | |
| 830 | agent => 'WebSTLs/1.0 (+https://webstls.com)', | |
| 831 | ); | |
| 832 | my %opts; | |
| 833 | $opts{headers} = { | |
| 834 | 'Accept' => 'application/json', | |
| 835 | 'Content-Type' => 'application/json', | |
| 836 | %$headers, | |
| 837 | }; | |
| 838 | if (defined $body_ref) { | |
| 839 | my $body = (ref $body_ref) ? eval { JSON::PP::encode_json($body_ref) } : "$body_ref"; | |
| 840 | if (!defined $body) { | |
| 841 | return { ok => 0, error => "Failed to encode request body as JSON: $@" }; | |
| 842 | } | |
| 843 | $opts{content} = $body; | |
| 844 | } | |
| 845 | my $r = $ua->request($method, $url, \%opts); | |
| 846 | ||
| 847 | my $parsed; | |
| 848 | if (defined $r->{content} && length $r->{content}) { | |
| 849 | $parsed = eval { JSON::PP::decode_json($r->{content}) }; | |
| 850 | } | |
| 851 | ||
| 852 | if ($r->{success}) { | |
| 853 | return { ok => 1, status => $r->{status}, data => $parsed }; | |
| 854 | } | |
| 855 | ||
| 856 | # Try to surface the platform's error message when present. | |
| 857 | my $err_msg = "HTTP $r->{status} $r->{reason}"; | |
| 858 | if ($parsed) { | |
| 859 | if (ref($parsed) eq 'HASH' && $parsed->{error_description}) { $err_msg .= ": $parsed->{error_description}"; } | |
| 860 | elsif (ref($parsed) eq 'HASH' && $parsed->{error}) { $err_msg .= ": " . (ref $parsed->{error} ? JSON::PP::encode_json($parsed->{error}) : $parsed->{error}); } | |
| 861 | elsif (ref($parsed) eq 'HASH' && $parsed->{message}) { $err_msg .= ": $parsed->{message}"; } | |
| 862 | elsif (ref($parsed) eq 'HASH' && $parsed->{errors}) { $err_msg .= ": " . (ref $parsed->{errors} ? JSON::PP::encode_json($parsed->{errors}) : $parsed->{errors}); } | |
| 863 | } elsif (defined $r->{content}) { | |
| 864 | my $snippet = substr($r->{content}, 0, 220); | |
| 865 | $snippet =~ s/\s+/ /g; | |
| 866 | $err_msg .= ": $snippet" if length $snippet; | |
| 867 | } | |
| 868 | return { ok => 0, status => $r->{status}, error => $err_msg }; | |
| 869 | } | |
| 870 | ||
| 871 | # Convenience: extract Bearer token from an account row. Adapters that | |
| 872 | # do simpler auth shapes (API key, Basic) build their own header. | |
| 873 | sub _bearer { | |
| 874 | my ($acct) = @_; | |
| 875 | return undef unless $acct && length($acct->{access_token} // ''); | |
| 876 | return 'Bearer ' . $acct->{access_token}; | |
| 877 | } | |
| 878 | ||
| 879 | # Stringify physical product fields adapters commonly need. | |
| 880 | sub _shipping_summary { | |
| 881 | my ($model) = @_; | |
| 882 | my @bits; | |
| 883 | push @bits, "Weight: $model->{weight_grams}g" | |
| 884 | if $model->{weight_grams}; | |
| 885 | push @bits, "Production time: $model->{production_time_days} days" | |
| 886 | if $model->{production_time_days}; | |
| 887 | push @bits, "Ships from: $model->{ship_from_country}" | |
| 888 | if $model->{ship_from_country}; | |
| 889 | return @bits ? join(' | ', @bits) : ''; | |
| 890 | } | |
| 891 | ||
| 892 | # ---- Per-platform push adapters ------------------------------------- | |
| 893 | # Each adapter receives ($self, $marketplace_account_row, $model_row) | |
| 894 | # and returns one of: | |
| 895 | # { ok => 1, remote_listing_id => '...', remote_url => 'https://...' } | |
| 896 | # { ok => 0, error => 'human-readable reason' } | |
| 897 | # | |
| 898 | # Replace the body of any stub below to wire that platform live; the | |
| 899 | # worker + queue + UI code already handles success/failure for free. | |
| 900 | ||
| 901 | #---------------------------------------------------------------------- | |
| 902 | # Cults3D push (REST API, Bearer token) | |
| 903 | # Docs: https://cults3d.com/en/api | |
| 904 | # Auth: API key from cults3d.com/en/api/keys with Write scope | |
| 905 | # Note: as of writing, Cults3D's public API documents read endpoints | |
| 906 | # (creations index + show). For uploads, Cults3D requires a multipart | |
| 907 | # call to POST /api/v1/creations with the STL attached. The endpoint | |
| 908 | # exists in their developer portal; we encode it best-effort here. | |
| 909 | #---------------------------------------------------------------------- | |
| 910 | sub _push_cults3d { | |
| 911 | my ($self, $acct, $model) = @_; | |
| 912 | my $token = $acct->{access_token}; | |
| 913 | unless (length($token || '')) { | |
| 914 | return { ok => 0, error => "Cults3D push needs an API token with Write scope. Open Marketplaces -> Cults3D and paste the token from cults3d.com/en/api/keys." }; | |
| 915 | } | |
| 916 | # Cults3D upload endpoint (per their API docs, /en/api/v1/creations | |
| 917 | # with multipart). Without the STL bytes uploaded here we can only | |
| 918 | # create a draft listing -- the seller still has to attach the file | |
| 919 | # on Cults3D once the row exists. | |
| 920 | my $body = { | |
| 921 | title => $model->{title}, | |
| 922 | description => ($model->{description} || $model->{tagline} || ''), | |
| 923 | license => $model->{license_type} || 'standard', | |
| 924 | price_eur => sprintf('%.2f', ($model->{base_price_cents} || 0) / 100), | |
| 925 | category => $model->{category} || 'other', | |
| 926 | }; | |
| 927 | my $r = $self->_http_json('POST', 'https://cults3d.com/en/api/v1/creations', | |
| 928 | { 'Authorization' => "Bearer $token" }, $body); | |
| 929 | if ($r->{ok}) { | |
| 930 | my $remote_id = (ref $r->{data} eq 'HASH' ? ($r->{data}->{id} || $r->{data}->{slug}) : '') || ''; | |
| 931 | my $remote_url = (ref $r->{data} eq 'HASH' ? ($r->{data}->{url} || $r->{data}->{share_url} || '') : '') || ''; | |
| 932 | if (!$remote_url && $remote_id) { | |
| 933 | $remote_url = "https://cults3d.com/en/3d-model/various/$remote_id"; | |
| 934 | } | |
| 935 | return { | |
| 936 | ok => 1, | |
| 937 | remote_listing_id => $remote_id, | |
| 938 | remote_url => $remote_url, | |
| 939 | }; | |
| 940 | } | |
| 941 | return { ok => 0, error => "Cults3D rejected the push -- $r->{error}. The seller may need to upload the STL bytes manually on cults3d.com to publish the listing." }; | |
| 942 | } | |
| 943 | ||
| 944 | #---------------------------------------------------------------------- | |
| 945 | # Gumroad push (REST API, Bearer token) | |
| 946 | # Docs: https://app.gumroad.com/api | |
| 947 | # Auth: access token from gumroad.com/settings/advanced -> Applications | |
| 948 | #---------------------------------------------------------------------- | |
| 949 | sub _push_gumroad { | |
| 950 | my ($self, $acct, $model) = @_; | |
| 951 | my $token = $acct->{access_token}; | |
| 952 | unless (length($token || '')) { | |
| 953 | return { ok => 0, error => "Gumroad push needs an access token. Open Marketplaces -> Gumroad and paste the token from gumroad.com/settings/advanced." }; | |
| 954 | } | |
| 955 | # Gumroad's product-create endpoint uses form-encoded data. | |
| 956 | # Documented at app.gumroad.com/api/products. | |
| 957 | my $price_cents = $model->{base_price_cents} || 0; | |
| 958 | my $body = { | |
| 959 | access_token => $token, | |
| 960 | name => $model->{title}, | |
| 961 | price => $price_cents, # in cents | |
| 962 | description => ($model->{description} || $model->{tagline} || ''), | |
| 963 | }; | |
| 964 | # Form-encoded, not JSON. | |
| 965 | my $ua = HTTP::Tiny->new(timeout => 30, agent => 'WebSTLs/1.0'); | |
| 966 | my $r = $ua->post_form('https://api.gumroad.com/v2/products', $body); | |
| 967 | my $parsed; | |
| 968 | $parsed = eval { JSON::PP::decode_json($r->{content} || '{}') } if defined $r->{content}; | |
| 969 | if ($r->{success} && $parsed && $parsed->{success}) { | |
| 970 | my $p = $parsed->{product} || {}; | |
| 971 | return { | |
| 972 | ok => 1, | |
| 973 | remote_listing_id => $p->{id} || '', | |
| 974 | remote_url => $p->{short_url} || $p->{custom_permalink} || '', | |
| 975 | }; | |
| 976 | } | |
| 977 | my $msg = "HTTP $r->{status} $r->{reason}"; | |
| 978 | if ($parsed && ref($parsed) eq 'HASH') { | |
| 979 | $msg .= ': ' . ($parsed->{message} || $parsed->{error} || JSON::PP::encode_json($parsed)); | |
| 980 | } elsif (defined $r->{content}) { | |
| 981 | my $sn = substr($r->{content}, 0, 220); $sn =~ s/\s+/ /g; | |
| 982 | $msg .= ": $sn"; | |
| 983 | } | |
| 984 | return { ok => 0, error => "Gumroad rejected the push -- $msg" }; | |
| 985 | } | |
| 986 | ||
| 987 | #---------------------------------------------------------------------- | |
| 988 | # MyMiniFactory push (REST API) | |
| 989 | # Docs: https://www.myminifactory.com/developers | |
| 990 | # Auth: API key issued via the MMF developers portal. | |
| 991 | # MMF's actual creation endpoint requires multipart STL upload + the | |
| 992 | # /api/v2/objects POST. Without the STL bytes here we create a stub | |
| 993 | # entry (title + description) that the seller can complete on the MMF | |
| 994 | # side. If MMF's API rejects no-file POSTs outright, that error is | |
| 995 | # surfaced verbatim so the seller knows why. | |
| 996 | #---------------------------------------------------------------------- | |
| 997 | sub _push_myminifactory { | |
| 998 | my ($self, $acct, $model) = @_; | |
| 999 | my $token = $acct->{access_token}; | |
| 1000 | unless (length($token || '')) { | |
| 1001 | return { ok => 0, error => "MyMiniFactory push needs an API key. Open Marketplaces -> MyMiniFactory and paste the key from myminifactory.com/developers." }; | |
| 1002 | } | |
| 1003 | my $body = { | |
| 1004 | name => $model->{title}, | |
| 1005 | description => ($model->{description} || $model->{tagline} || ''), | |
| 1006 | price => sprintf('%.2f', ($model->{base_price_cents} || 0) / 100), | |
| 1007 | category => $model->{category} || 'miscellaneous', | |
| 1008 | license => $model->{license_type} || 'cc-by', | |
| 1009 | published => JSON::PP::false, # create as draft | |
| 1010 | }; | |
| 1011 | my $r = $self->_http_json('POST', 'https://www.myminifactory.com/api/v2/objects', | |
| 1012 | { 'Authorization' => "Bearer $token" }, $body); | |
| 1013 | if ($r->{ok}) { | |
| 1014 | my $id = (ref $r->{data} eq 'HASH' ? $r->{data}->{id} : '') || ''; | |
| 1015 | my $url = (ref $r->{data} eq 'HASH' ? ($r->{data}->{url} || $r->{data}->{web_url}) : '') || ''; | |
| 1016 | if (!$url && $id && $acct->{account_handle}) { | |
| 1017 | $url = "https://www.myminifactory.com/object/3d-print-$id"; | |
| 1018 | } | |
| 1019 | return { ok => 1, remote_listing_id => $id, remote_url => $url }; | |
| 1020 | } | |
| 1021 | return { ok => 0, error => "MyMiniFactory rejected the push -- $r->{error}. The seller may need to upload the STL on myminifactory.com to publish." }; | |
| 1022 | } | |
| 1023 | ||
| 1024 | #---------------------------------------------------------------------- | |
| 1025 | # Patreon push (creator post with attachment) | |
| 1026 | # Docs: https://docs.patreon.com/ | |
| 1027 | # Patreon has no "products" concept -- it is a membership platform. | |
| 1028 | # The closest analog is creating a post on the seller's campaign that | |
| 1029 | # announces the new model with a download link or attachment. That is | |
| 1030 | # what this adapter does: POST a creator-visible draft post. | |
| 1031 | #---------------------------------------------------------------------- | |
| 1032 | sub _push_patreon { | |
| 1033 | my ($self, $acct, $model) = @_; | |
| 1034 | my $token = $acct->{access_token}; | |
| 1035 | unless (length($token || '')) { | |
| 1036 | return { ok => 0, error => "Patreon push needs a creator access token. Open Marketplaces -> Patreon and paste the token from patreon.com/portal/registration." }; | |
| 1037 | } | |
| 1038 | # 1) Look up the creator's campaign id (Patreon needs it on the post). | |
| 1039 | my $camp = $self->_http_json('GET', | |
| 1040 | 'https://www.patreon.com/api/oauth2/v2/campaigns', | |
| 1041 | { 'Authorization' => "Bearer $token" }); | |
| 1042 | unless ($camp->{ok} && ref($camp->{data}) eq 'HASH' | |
| 1043 | && ref($camp->{data}->{data}) eq 'ARRAY' | |
| 1044 | && @{ $camp->{data}->{data} }) { | |
| 1045 | return { ok => 0, error => "Patreon: couldn't look up your campaign (token scope must include campaigns / w:campaigns.posts). $camp->{error}" }; | |
| 1046 | } | |
| 1047 | my $campaign_id = $camp->{data}->{data}->[0]->{id}; | |
| 1048 | ||
| 1049 | my $body = { | |
| 1050 | data => { | |
| 1051 | type => 'post', | |
| 1052 | attributes => { | |
| 1053 | title => "New 3D model: $model->{title}", | |
| 1054 | content => ($model->{description} || $model->{tagline} || '') | |
| 1055 | . qq~\n\nGet it on WebSTLs: https://3dshawn.webstls.com/?model=$model->{id}~, | |
| 1056 | post_type => 'text_only', | |
| 1057 | }, | |
| 1058 | relationships => { | |
| 1059 | campaign => { data => { type => 'campaign', id => $campaign_id } }, | |
| 1060 | }, | |
| 1061 | }, | |
| 1062 | }; | |
| 1063 | my $r = $self->_http_json('POST', 'https://www.patreon.com/api/oauth2/v2/posts', | |
| 1064 | { 'Authorization' => "Bearer $token" }, $body); | |
| 1065 | if ($r->{ok}) { | |
| 1066 | my $pid = ''; | |
| 1067 | my $url = ''; | |
| 1068 | if (ref($r->{data}) eq 'HASH' && ref($r->{data}->{data}) eq 'HASH') { | |
| 1069 | $pid = $r->{data}->{data}->{id} || ''; | |
| 1070 | $url = ($r->{data}->{data}->{attributes} || {})->{url} || ''; | |
| 1071 | } | |
| 1072 | return { ok => 1, remote_listing_id => $pid, remote_url => $url }; | |
| 1073 | } | |
| 1074 | return { ok => 0, error => "Patreon rejected the post -- $r->{error}. Patreon's posts API requires the w:campaigns.posts scope on your access token." }; | |
| 1075 | } | |
| 1076 | ||
| 1077 | #---------------------------------------------------------------------- | |
| 1078 | # Printables push -- no public push API yet. | |
| 1079 | # Prusa exposes search/read endpoints at api.printables.com but uploads | |
| 1080 | # are gated to first-party clients (the web uploader). We make the | |
| 1081 | # best-effort attempt to call the same endpoint the web client uses; | |
| 1082 | # in practice this currently 403s for third-party tokens. We still | |
| 1083 | # return a clean, actionable error so the seller knows what is up. | |
| 1084 | #---------------------------------------------------------------------- | |
| 1085 | sub _push_printables { | |
| 1086 | my ($self, $acct, $model) = @_; | |
| 1087 | my $token = $acct->{access_token}; | |
| 1088 | unless (length($token || '')) { | |
| 1089 | return { ok => 0, error => "Printables push needs an API token. Note: as of writing, Printables does not publicly accept third-party uploads -- the seller may need to upload manually at printables.com/upload." }; | |
| 1090 | } | |
| 1091 | # Attempt the GraphQL createPrint mutation. Printables uses GraphQL | |
| 1092 | # at api.printables.com/graphql; this is the schema the web client | |
| 1093 | # uses. If they 403 third-party clients (likely), we surface the | |
| 1094 | # error verbatim so the seller can act on it. | |
| 1095 | my $body = { | |
| 1096 | operationName => 'createPrint', | |
| 1097 | variables => { | |
| 1098 | input => { | |
| 1099 | name => $model->{title}, | |
| 1100 | description => ($model->{description} || $model->{tagline} || ''), | |
| 1101 | price => sprintf('%.2f', ($model->{base_price_cents} || 0) / 100), | |
| 1102 | isPaid => JSON::PP::true, | |
| 1103 | }, | |
| 1104 | }, | |
| 1105 | query => 'mutation createPrint($input: CreatePrintInput!) { createPrint(input: $input) { id slug url } }', | |
| 1106 | }; | |
| 1107 | my $r = $self->_http_json('POST', 'https://api.printables.com/graphql/', | |
| 1108 | { 'Authorization' => "Bearer $token" }, $body); | |
| 1109 | if ($r->{ok} && ref($r->{data}) eq 'HASH' && ref($r->{data}->{data}) eq 'HASH') { | |
| 1110 | my $cp = $r->{data}->{data}->{createPrint}; | |
| 1111 | if ($cp && ref($cp) eq 'HASH' && $cp->{id}) { | |
| 1112 | return { | |
| 1113 | ok => 1, | |
| 1114 | remote_listing_id => $cp->{id} || $cp->{slug} || '', | |
| 1115 | remote_url => $cp->{url} || ('https://www.printables.com/model/' . ($cp->{slug} || $cp->{id})), | |
| 1116 | }; | |
| 1117 | } | |
| 1118 | } | |
| 1119 | return { ok => 0, error => "Printables push failed -- $r->{error}. As of writing, Printables restricts the upload API to first-party clients. The seller can upload manually at printables.com/upload." }; | |
| 1120 | } | |
| 1121 | ||
| 1122 | #---------------------------------------------------------------------- | |
| 1123 | # Thangs push -- no public push API yet. | |
| 1124 | # Thangs gates upload behind a logged-in browser flow; the storefront | |
| 1125 | # /api endpoints accept reads but reject creator-upload POSTs from | |
| 1126 | # third-party tokens. Best-effort attempt + actionable error. | |
| 1127 | #---------------------------------------------------------------------- | |
| 1128 | sub _push_thangs { | |
| 1129 | my ($self, $acct, $model) = @_; | |
| 1130 | return { ok => 0, error => "Thangs does not currently expose a public creator-upload API. The seller needs to upload manually at thangs.com/upload. (If Thangs ever ships a public upload endpoint, wire it in _push_thangs in MODS/WebSTLs/Marketplaces.pm.)" }; | |
| 1131 | } | |
| 1132 | ||
| 1133 | #---------------------------------------------------------------------- | |
| 1134 | # Etsy push (REST API v3, OAuth2 PKCE) | |
| 1135 | # Docs: https://developers.etsy.com/documentation/reference/ | |
| 1136 | # Auth: OAuth2 user token with scope listings_w + shop_w | |
| 1137 | # Etsy requires the shop id on every listing create. We resolve it | |
| 1138 | # from the account row's metadata; if it isn't stored yet, we look it | |
| 1139 | # up at the /users/me endpoint. | |
| 1140 | #---------------------------------------------------------------------- | |
| 1141 | sub _push_etsy { | |
| 1142 | my ($self, $acct, $model) = @_; | |
| 1143 | my $token = $acct->{access_token}; | |
| 1144 | unless (length($token || '')) { | |
| 1145 | return { ok => 0, error => "Etsy push needs an OAuth user token (scopes listings_w + shop_w). Open Marketplaces -> Etsy and complete the developer.etsy.com OAuth flow." }; | |
| 1146 | } | |
| 1147 | # Etsy requires the API key (client_id) as a request header AND the | |
| 1148 | # bearer token. The client_id is platform-wide and lives in our | |
| 1149 | # config; without it the call would 401. For now we read it from | |
| 1150 | # MODS::WebSTLs::Config; sellers don't need to supply it themselves. | |
| 1151 | require MODS::WebSTLs::Config; | |
| 1152 | my $cfg = MODS::WebSTLs::Config->new; | |
| 1153 | my $client_id = $cfg->settings('etsy_client_id') || ''; | |
| 1154 | unless (length $client_id) { | |
| 1155 | return { ok => 0, error => "Etsy push needs the platform's Etsy API client_id configured under settings.etsy_client_id (admin -> Settings -> Available Features). Once set, every seller's connected Etsy push will route through it." }; | |
| 1156 | } | |
| 1157 | ||
| 1158 | # Resolve shop_id. Try the account row first (we should be storing | |
| 1159 | # it on connect); fall back to /users/me/shops. | |
| 1160 | my $shop_id = ''; | |
| 1161 | if ($acct->{metadata} && $acct->{metadata} =~ /"shop_id"\s*:\s*"?(\d+)/) { | |
| 1162 | $shop_id = $1; | |
| 1163 | } | |
| 1164 | if (!$shop_id) { | |
| 1165 | my $me = $self->_http_json('GET', 'https://openapi.etsy.com/v3/application/users/me', | |
| 1166 | { 'x-api-key' => $client_id, 'Authorization' => "Bearer $token" }); | |
| 1167 | if ($me->{ok} && ref($me->{data}) eq 'HASH' && $me->{data}->{shop_id}) { | |
| 1168 | $shop_id = $me->{data}->{shop_id}; | |
| 1169 | } | |
| 1170 | } | |
| 1171 | unless ($shop_id) { | |
| 1172 | return { ok => 0, error => "Etsy: couldn't resolve your shop_id. Reconnect Etsy in Marketplaces, or paste the numeric shop_id manually in the connect modal." }; | |
| 1173 | } | |
| 1174 | ||
| 1175 | my $price_dollars = sprintf('%.2f', ($model->{base_price_cents} || 0) / 100); | |
| 1176 | my $kind = $model->{product_kind} || 'digital'; | |
| 1177 | my $body = { | |
| 1178 | quantity => $kind eq 'physical' ? 1 : 999, | |
| 1179 | title => $model->{title}, | |
| 1180 | description => ($model->{description} || $model->{tagline} || ''), | |
| 1181 | price => $price_dollars + 0, | |
| 1182 | who_made => 'i_did', | |
| 1183 | when_made => 'made_to_order', | |
| 1184 | taxonomy_id => 6862, # "Digital prints" -- conservative default; seller can re-categorize on Etsy | |
| 1185 | type => $kind eq 'physical' ? 'physical' : 'download', | |
| 1186 | state => 'draft', # create as draft -- seller publishes from Etsy | |
| 1187 | is_supply => JSON::PP::false, | |
| 1188 | shipping_profile_id => undef, | |
| 1189 | }; | |
| 1190 | if ($kind ne 'physical') { | |
| 1191 | # Digital listings need a shipping_profile_id only when type is physical. | |
| 1192 | delete $body->{shipping_profile_id}; | |
| 1193 | } | |
| 1194 | ||
| 1195 | my $url = "https://openapi.etsy.com/v3/application/shops/$shop_id/listings"; | |
| 1196 | my $r = $self->_http_json('POST', $url, | |
| 1197 | { 'x-api-key' => $client_id, 'Authorization' => "Bearer $token" }, $body); | |
| 1198 | ||
| 1199 | if ($r->{ok} && ref($r->{data}) eq 'HASH' && $r->{data}->{listing_id}) { | |
| 1200 | my $lid = $r->{data}->{listing_id}; | |
| 1201 | return { | |
| 1202 | ok => 1, | |
| 1203 | remote_listing_id => $lid, | |
| 1204 | remote_url => "https://www.etsy.com/listing/$lid", | |
| 1205 | }; | |
| 1206 | } | |
| 1207 | return { ok => 0, error => "Etsy rejected the listing -- $r->{error}. Check the token scopes (listings_w + shop_w) and that the shop is approved for selling." }; | |
| 1208 | } | |
| 1209 | ||
| 1210 | 1; |