Diff -- /var/www/vhosts/webstls.com/httpdocs/MODS/WebSTLs/Marketplaces.pm
Diff

/var/www/vhosts/webstls.com/httpdocs/MODS/WebSTLs/Marketplaces.pm

added on WebSTLs (webstls.com) at 2026-07-01 22:26:39

Added
+1210
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 7f267f38f30d
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package 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#======================================================================
28use strict;
29use warnings;
30use HTTP::Tiny;
31use JSON::PP;
32
33our @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#----------------------------------------------------------------------
277our %CRED_FIELDS;
278%CRED_FIELDS = (
279 etsy => [
280 { name=>'account_handle', label=>'Etsy shop name', hint=>'The slug after etsy.com/shop/ &mdash; 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> &rarr; My Apps &rarr; 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 &rarr; Advanced &rarr; 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 &mdash; 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 &mdash; 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> &rarr; click <strong>ADD</strong> &rarr; name it (e.g. &quot;WebSTLs&quot;) &rarr; check <strong>Read</strong> (and <strong>Write</strong> if you want push too) &rarr; <strong>Create API Key</strong>. Copy the displayed code immediately &mdash; 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/&lt;username&gt;.', 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/&lt;username&gt;.', 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/&lt;slug&gt;.', 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/&lt;slug&gt;.', 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/&lt;handle&gt;.', 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/&lt;username&gt;.', type=>'text', required=>1, secret=>0 },
334 { name=>'access_token', label=>'Ko-fi API key', hint=>'Generate at Ko-fi &rarr; Settings &rarr; API.', type=>'password', required=>1, secret=>1 },
335 ],
336 makerworld => [
337 { name=>'account_handle', label=>'MakerWorld username', hint=>'Your makerworld.com/&lt;username&gt;.', 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 &rarr; Apps &rarr; Develop apps &rarr; 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
358sub new { return bless({}, shift) }
359
360# Return the full platform list (shallow copies).
361sub 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.
365sub digital_only {
366 my $self = shift;
367 return [ grep { $_->{sells_digital} } map { { %$_ } } @PLATFORMS ];
368}
369sub 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').
375sub 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.
387sub 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.
400sub 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.
416our %IMPORT_SUPPORTED = (
417 cults3d => 'auto',
418 thangs => 'manual',
419);
420sub 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}
426sub 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.
439sub _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).
450sub 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.
471sub 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.
499sub 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).
603sub 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.
629sub 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.
644sub 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
683sub 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.
708sub 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.
762sub _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.
790sub _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 }
825sub _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.
873sub _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.
880sub _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#----------------------------------------------------------------------
910sub _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#----------------------------------------------------------------------
949sub _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#----------------------------------------------------------------------
997sub _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#----------------------------------------------------------------------
1032sub _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#----------------------------------------------------------------------
1085sub _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#----------------------------------------------------------------------
1128sub _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#----------------------------------------------------------------------
1141sub _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
12101;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help