added on WebSTLs (webstls.com) at 2026-07-01 22:26:34
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs -- marketplace integrations dashboard. | |
| 4 | # | |
| 5 | # GET /marketplaces.cgi | |
| 6 | # Lists every supported platform with per-creator connection state, | |
| 7 | # pulled from `marketplace_accounts` (when the table exists). | |
| 8 | # | |
| 9 | # POST /marketplaces.cgi | |
| 10 | # action=connect slug=etsy + per-platform credential fields | |
| 11 | # -> writes/updates marketplace_accounts, flips | |
| 12 | # status='connected', redirects back here. | |
| 13 | # action=disconnect slug=etsy | |
| 14 | # -> clears tokens + flips status='disconnected'. | |
| 15 | # | |
| 16 | # Per-platform API push code (the actual HTTP calls that ship a model | |
| 17 | # to Etsy/Cults3D/etc.) is its own multi-session project gated on | |
| 18 | # platform dev-account approval. This file owns the credential-storage | |
| 19 | # framework; pushes drop in later via the queue_push pipeline in | |
| 20 | # MODS::WebSTLs::Marketplaces. | |
| 21 | #====================================================================== | |
| 22 | use strict; | |
| 23 | use warnings; | |
| 24 | ||
| 25 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 26 | use CGI; | |
| 27 | use MODS::Template; | |
| 28 | use MODS::DBConnect; | |
| 29 | use MODS::Login; | |
| 30 | use MODS::WebSTLs::Config; | |
| 31 | use MODS::WebSTLs::Wrapper; | |
| 32 | use MODS::WebSTLs::Marketplaces; | |
| 33 | use MODS::WebSTLs::ImportAdapters; | |
| 34 | ||
| 35 | my $q = CGI->new; | |
| 36 | my $form = $q->Vars; | |
| 37 | my $tfile = MODS::Template->new; | |
| 38 | my $db = MODS::DBConnect->new; | |
| 39 | my $auth = MODS::Login->new; | |
| 40 | my $config = MODS::WebSTLs::Config->new; | |
| 41 | my $wrap = MODS::WebSTLs::Wrapper->new; | |
| 42 | my $mp = MODS::WebSTLs::Marketplaces->new; | |
| 43 | my $imp = MODS::WebSTLs::ImportAdapters->new; | |
| 44 | my $DB = $config->settings('database_name'); | |
| 45 | ||
| 46 | $|=1; | |
| 47 | ||
| 48 | my $userinfo = $auth->login_verify(); | |
| 49 | if (!$userinfo) { | |
| 50 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 51 | exit; | |
| 52 | } | |
| 53 | ||
| 54 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'marketplaces'; | |
| 55 | ||
| 56 | if ($entry eq 'marketplace_action') { | |
| 57 | # POST-only. Drive-by GETs cannot mutate state. | |
| 58 | if (($ENV{REQUEST_METHOD} || '') ne 'POST') { | |
| 59 | print "Status: 405 Method Not Allowed\nContent-Type: text/plain\n\nPOST required\n"; | |
| 60 | exit; | |
| 61 | } | |
| 62 | require MODS::WebSTLs::Permissions; | |
| 63 | MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'manage_marketplaces'); | |
| 64 | _handle_action($q, $form, $userinfo); | |
| 65 | exit; | |
| 66 | } | |
| 67 | ||
| 68 | require MODS::WebSTLs::Permissions; | |
| 69 | MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'manage_marketplaces'); | |
| 70 | ||
| 71 | my $uid = $userinfo->{user_id}; | |
| 72 | $uid =~ s/[^0-9]//g; | |
| 73 | ||
| 74 | sub _h { my $s = shift; $s = '' unless defined $s; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; return $s; } | |
| 75 | ||
| 76 | # Escape a string for inclusion as a JS string literal (single-quoted). | |
| 77 | sub _js_str { | |
| 78 | my $s = shift; $s = '' unless defined $s; | |
| 79 | $s =~ s/\\/\\\\/g; $s =~ s/'/\\'/g; | |
| 80 | $s =~ s/\r/\\r/g; $s =~ s/\n/\\n/g; | |
| 81 | $s =~ s|</|<\\/|g; | |
| 82 | return "'" . $s . "'"; | |
| 83 | } | |
| 84 | # Escape a URL/string for use inside a double-quoted HTML attribute. | |
| 85 | sub _attr_str { | |
| 86 | my $s = shift; $s = '' unless defined $s; | |
| 87 | $s =~ s/&/&/g; $s =~ s/"/"/g; | |
| 88 | $s =~ s/</</g; $s =~ s/>/>/g; | |
| 89 | return $s; | |
| 90 | } | |
| 91 | ||
| 92 | # ---- POST handlers: connect / disconnect ---------------------------- | |
| 93 | # POST-Redirect-GET so refresh-after-connect doesn't re-submit the form. | |
| 94 | # Outcome is encoded into the redirect query string so the GET render | |
| 95 | # can surface a success / error banner inline with the platform cards. | |
| 96 | my $is_post = (($ENV{REQUEST_METHOD} || '') eq 'POST') ? 1 : 0; | |
| 97 | if ($is_post) { | |
| 98 | my $action = $form->{action} || ''; | |
| 99 | my $slug = $form->{slug} || ''; | |
| 100 | $slug = lc($slug); $slug =~ s/[^a-z0-9]//g; | |
| 101 | my $platform = $mp->by_slug($slug); | |
| 102 | ||
| 103 | if (!$platform) { | |
| 104 | print "Status: 302 Found\nLocation: /marketplaces.cgi?err=bad_platform\n\n"; | |
| 105 | exit; | |
| 106 | } | |
| 107 | ||
| 108 | my $dbh = $db->db_connect(); | |
| 109 | ||
| 110 | if ($action eq 'connect') { | |
| 111 | my $spec = $mp->cred_fields($slug) || []; | |
| 112 | my %fields; | |
| 113 | foreach my $f (@$spec) { | |
| 114 | $fields{ $f->{name} } = $form->{ $f->{name} }; | |
| 115 | } | |
| 116 | my $r = $mp->connect_account($db, $dbh, $DB, $uid, $slug, \%fields); | |
| 117 | $db->db_disconnect($dbh); | |
| 118 | if ($r->{ok}) { | |
| 119 | print "Status: 302 Found\nLocation: /marketplaces.cgi?ok=connected&slug=$slug\n\n"; | |
| 120 | } else { | |
| 121 | my $err = $r->{err} || 'unknown'; | |
| 122 | print "Status: 302 Found\nLocation: /marketplaces.cgi?err=$err&slug=$slug\n\n"; | |
| 123 | } | |
| 124 | exit; | |
| 125 | } | |
| 126 | elsif ($action eq 'disconnect') { | |
| 127 | $mp->disconnect_account($db, $dbh, $DB, $uid, $slug); | |
| 128 | $db->db_disconnect($dbh); | |
| 129 | print "Status: 302 Found\nLocation: /marketplaces.cgi?ok=disconnected&slug=$slug\n\n"; | |
| 130 | exit; | |
| 131 | } | |
| 132 | elsif ($action eq 'import_models' || $action eq 'import_models_manual') { | |
| 133 | # Auto path = ImportAdapters scrapes / hits the platform's API. | |
| 134 | # Manual path = user pastes a list of model URLs (used when the | |
| 135 | # platform sits behind Cloudflare so a server-side fetch can't | |
| 136 | # reach it, e.g. Thangs). Dedup logic is shared so re-runs from | |
| 137 | # either path skip already-imported entries. | |
| 138 | # | |
| 139 | # ---- Streaming spinner page with in-place loop ---- | |
| 140 | # The whole import runs on one page-load: this script imports | |
| 141 | # one model, flushes a <script> to update the banner count, | |
| 142 | # then loops to the next model -- no redirect between batches. | |
| 143 | # The user sees one continuous spinner page that announces each | |
| 144 | # model as it lands, then a single redirect at the end. | |
| 145 | # | |
| 146 | # Two failure modes are handled: | |
| 147 | # 1. nginx's proxy_read_timeout would 504 a quiet upstream. | |
| 148 | # A forked heartbeat child prints a single space every 4s | |
| 149 | # so nginx always has fresh bytes to forward. | |
| 150 | # 2. User clicks Stop / closes the tab. The parent's flush | |
| 151 | # returns false on EPIPE; we break the loop, kill the | |
| 152 | # heartbeat, and exit. The next browser load picks up | |
| 153 | # from wherever dedup left off. | |
| 154 | # X-Accel-Buffering: no opts this response out of nginx's | |
| 155 | # proxy_buffering so the spinner card paints immediately | |
| 156 | # rather than waiting for the buffer to fill. | |
| 157 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\nX-Accel-Buffering: no\n\n"; | |
| 158 | my $platform_label = ucfirst($slug || 'marketplace'); | |
| 159 | $platform_label = 'Cults3D' if $slug eq 'cults3d'; | |
| 160 | $platform_label = 'MyMiniFactory' if $slug eq 'myminifactory'; | |
| 161 | $platform_label = 'Thangs' if $slug eq 'thangs'; | |
| 162 | $platform_label = 'Printables' if $slug eq 'printables'; | |
| 163 | $platform_label = 'Thingiverse' if $slug eq 'thingiverse'; | |
| 164 | ||
| 165 | # Running counts threaded through from a previous Continue | |
| 166 | # click (after a user-triggered Stop). On a fresh Import-button | |
| 167 | # click these arrive as 0/0. | |
| 168 | my $running_done = $form->{running_done} || ''; | |
| 169 | my $running_total = $form->{running_total} || ''; | |
| 170 | $running_done =~ s/[^0-9]//g; $running_done ||= 0; | |
| 171 | $running_total =~ s/[^0-9]//g; $running_total ||= 0; | |
| 172 | my $slug_js = _js_str($slug); | |
| 173 | # Initial stop URL: if the user clicks Stop before any model | |
| 174 | # finishes, we still need to get them back somewhere sane. With | |
| 175 | # zero running counts that's just the plain marketplaces page | |
| 176 | # (no banner). Once the first model lands, the server overwrites | |
| 177 | # window._impStopUrl with a URL that carries the running count. | |
| 178 | my $initial_stop_url = ($running_done > 0) | |
| 179 | ? "/marketplaces.cgi?ok=imported&slug=$slug&done=$running_done&total=$running_total&more=0" | |
| 180 | : "/marketplaces.cgi"; | |
| 181 | my $initial_stop_url_js = _js_str($initial_stop_url); | |
| 182 | print <<"HTML"; | |
| 183 | <!DOCTYPE html> | |
| 184 | <html lang="en"> | |
| 185 | <head> | |
| 186 | <meta charset="UTF-8"> | |
| 187 | <title>Importing from $platform_label...</title> | |
| 188 | <style> | |
| 189 | html, body { background: #050813; color: #cbd5e1; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; min-height: 100vh; } | |
| 190 | .imp-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; } | |
| 191 | .imp-card { position: relative; max-width: 580px; width: 100%; background: #0f172a; border: 1px solid rgba(124,58,237,0.55); border-radius: 16px; padding: 150px 36px 32px; text-align: center; box-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 60px rgba(124,58,237,0.15); } | |
| 192 | .imp-bigcount { position: absolute; top: 22px; left: 19px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", system-ui, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background: linear-gradient(135deg, rgba(59,130,246,0.22) 0%, rgba(37,99,235,0.18) 100%); border: 1px solid rgba(59,130,246,0.55); border-radius: 12px; padding: 16px 24px 14px; } | |
| 193 | .imp-bigcount-verb { display: block; font-size: 13px; font-weight: 700; color: #93c5fd; letter-spacing: 0.6px; margin-bottom: 4px; } | |
| 194 | .imp-bigcount-num { font-size: 60px; font-weight: 800; line-height: 1; letter-spacing: -1.5px; color: #60a5fa; white-space: nowrap; } | |
| 195 | .imp-bigcount-of { font-size: 24px; color: #475569; font-weight: 600; margin: 0 10px; vertical-align: 12px; } | |
| 196 | .imp-bigcount-total { color: #bfdbfe; } | |
| 197 | .imp-bigcount-label { font-size: 10px; letter-spacing: 1.8px; color: #64748b; text-transform: uppercase; font-weight: 700; margin-top: 8px; } | |
| 198 | .imp-spin { width: 48px; height: 48px; border: 3px solid rgba(124,58,237,0.30); border-top-color: #a78bfa; border-radius: 50%; animation: imp-rot 0.9s linear infinite; margin: 0 auto 24px; } | |
| 199 | \@keyframes imp-rot { to { transform: rotate(360deg); } } | |
| 200 | .imp-eyebrow { font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: #a78bfa; font-weight: 700; margin-bottom: 12px; } | |
| 201 | .imp-title { font-size: 22px; font-weight: 700; color: #fff; margin: 0 0 12px; line-height: 1.25; } | |
| 202 | .imp-sub { font-size: 14px; color: #cbd5e1; line-height: 1.55; margin: 0 0 8px; } | |
| 203 | .imp-hint { font-size: 12px; color: #94a3b8; margin-top: 16px; margin-bottom: 24px; } | |
| 204 | .imp-dots { display: inline-block; min-width: 18px; text-align: left; } | |
| 205 | .imp-dots span { opacity: 0; animation: imp-dot 1.4s infinite; } | |
| 206 | .imp-dots span:nth-child(2) { animation-delay: 0.2s; } | |
| 207 | .imp-dots span:nth-child(3) { animation-delay: 0.4s; } | |
| 208 | \@keyframes imp-dot { 0%, 60%, 100% { opacity: 0; } 30% { opacity: 1; } } | |
| 209 | .imp-stop { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.40); color: #f87171; border-radius: 8px; padding: 9px 22px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; transition: background 0.15s, color 0.15s; } | |
| 210 | .imp-stop:hover { background: rgba(239,68,68,0.22); color: #fca5a5; } | |
| 211 | </style> | |
| 212 | </head> | |
| 213 | <body> | |
| 214 | <div class="imp-wrap"> | |
| 215 | <div class="imp-card"> | |
| 216 | <div class="imp-bigcount"> | |
| 217 | <span class="imp-bigcount-verb" id="imp-bc-verb">Pulling</span> | |
| 218 | <div class="imp-bigcount-num"><span id="imp-bc-done">@{[ $running_done || 0 ]}</span><span class="imp-bigcount-of">of</span><span id="imp-bc-total" class="imp-bigcount-total">@{[ $running_total || '?' ]}</span></div> | |
| 219 | <div class="imp-bigcount-label" id="imp-bc-label">starting…</div> | |
| 220 | </div> | |
| 221 | <div class="imp-spin"></div> | |
| 222 | <div class="imp-eyebrow">$platform_label import</div> | |
| 223 | <h1 class="imp-title">Pulling your models<span class="imp-dots"><span>.</span><span>.</span><span>.</span></span></h1> | |
| 224 | <p class="imp-sub">We'll keep working until every model is in. Each model can take up to a minute on large galleries.</p> | |
| 225 | <p class="imp-hint">Keep this tab open. We'll redirect you to Marketplaces when we're done.</p> | |
| 226 | <button type="button" class="imp-stop" onclick="window._mpImpStop()">Stop import</button> | |
| 227 | </div> | |
| 228 | </div> | |
| 229 | <script> | |
| 230 | // Stop URL gets rewritten by the server after each model lands so it | |
| 231 | // always carries the latest running count. Clicking Stop just nav- | |
| 232 | // igates -- the server detects the broken pipe and exits cleanly. | |
| 233 | window._impStopUrl = $initial_stop_url_js; | |
| 234 | window._mpImpStop = function () { | |
| 235 | try { sessionStorage.setItem('mp_auto_stopped_' + $slug_js, '1'); } catch (e) {} | |
| 236 | window.location.replace(window._impStopUrl || '/marketplaces.cgi'); | |
| 237 | }; | |
| 238 | </script> | |
| 239 | HTML | |
| 240 | STDOUT->autoflush(1); | |
| 241 | STDOUT->flush; | |
| 242 | ||
| 243 | my $hb_pid = fork(); | |
| 244 | if (defined $hb_pid && $hb_pid == 0) { | |
| 245 | # Heartbeat child: print a single space every 4s. Whitespace | |
| 246 | # in <body> collapses in the rendered DOM, so it doesn't | |
| 247 | # show. Each print is one atomic write() so it can't tear | |
| 248 | # the parent's larger <script> writes. | |
| 249 | eval { $dbh->{InactiveDestroy} = 1 if $dbh; }; | |
| 250 | while (1) { | |
| 251 | sleep 4; | |
| 252 | print " "; | |
| 253 | last unless STDOUT->flush; | |
| 254 | } | |
| 255 | exit 0; | |
| 256 | } | |
| 257 | ||
| 258 | my $aborted = 0; | |
| 259 | my $final_result = { ok => 1, more_available => 0 }; | |
| 260 | my $loop_cap = 500; # safety: never run more iterations | |
| 261 | # than this in a single page-load, | |
| 262 | # even if dedup gets confused. | |
| 263 | # "skipped" on the FIRST iteration = pre-existing dupes (models | |
| 264 | # the user already had from a previous import). After iter 1 the | |
| 265 | # adapter's skipped count compounds (it includes models we just | |
| 266 | # imported earlier in this same loop) so we can't sum across | |
| 267 | # iterations -- capture once and stop. | |
| 268 | my $running_dups = -1; | |
| 269 | ||
| 270 | while ($loop_cap-- > 0) { | |
| 271 | my $r; | |
| 272 | if ($action eq 'import_models_manual') { | |
| 273 | my $urls = $form->{urls} || ''; | |
| 274 | $r = $imp->import_manual_for_user($db, $dbh, $DB, $uid, $slug, $urls); | |
| 275 | } else { | |
| 276 | $r = $imp->import_for_user($db, $dbh, $DB, $uid, $slug); | |
| 277 | } | |
| 278 | $final_result = $r; | |
| 279 | last unless $r && $r->{ok}; | |
| 280 | ||
| 281 | # Only the NEW imports this iteration count toward our | |
| 282 | # running total. The adapter's "skipped" is how many of | |
| 283 | # the platform's full list were already in DB (= every | |
| 284 | # model we've imported so far in this loop, plus anything | |
| 285 | # the user already had). Adding skipped would compound | |
| 286 | # iter-by-iter into nonsense numbers (1, 3, 6, 10, ...). | |
| 287 | my $n = int($r->{imported} || 0); | |
| 288 | $running_done += $n; | |
| 289 | my $platform_total = int($r->{total_available} || 0); | |
| 290 | $running_total = $platform_total if $platform_total; | |
| 291 | $running_total = $running_done if $running_total < $running_done; | |
| 292 | $running_dups = int($r->{skipped} || 0) if $running_dups < 0; | |
| 293 | ||
| 294 | # Push the new count to the Stop button's URL so if the | |
| 295 | # user hits Stop right now, they'll land on the marketplaces | |
| 296 | # page with the accurate "Imported X of Y" banner. Same | |
| 297 | # script tag also updates the visible "X of Y" progress | |
| 298 | # line on the card so the user can see we're alive even | |
| 299 | # on a 20-minute import. Single atomic print so the | |
| 300 | # heartbeat byte can't tear it. | |
| 301 | my $stop_total = $running_total >= $running_done ? $running_total : $running_done; | |
| 302 | my $stop_dups = $running_dups > 0 ? $running_dups : 0; | |
| 303 | my $stop_url = "/marketplaces.cgi?ok=imported&slug=$slug&done=$running_done&total=$stop_total&dups=$stop_dups&more=0"; | |
| 304 | my $bc_verb = $r->{more_available} ? 'Pulling' : 'Imported'; | |
| 305 | my $bc_label = $r->{more_available} | |
| 306 | ? "models imported \xc2\xb7 pulling next" | |
| 307 | : "all done \xc2\xb7 redirecting"; | |
| 308 | print qq~<script>window._impStopUrl=~ . _js_str($stop_url) | |
| 309 | . qq~;document.getElementById('imp-bc-verb').textContent=~ . _js_str($bc_verb) | |
| 310 | . qq~;document.getElementById('imp-bc-done').textContent=~ . $running_done | |
| 311 | . qq~;document.getElementById('imp-bc-total').textContent=~ . $stop_total | |
| 312 | . qq~;document.getElementById('imp-bc-label').textContent=~ . _js_str($bc_label) | |
| 313 | . qq~;</script>\n~; | |
| 314 | unless (STDOUT->flush) { | |
| 315 | # Browser closed the page (Stop button or tab close). | |
| 316 | $aborted = 1; | |
| 317 | last; | |
| 318 | } | |
| 319 | ||
| 320 | last unless $r->{more_available}; | |
| 321 | } | |
| 322 | ||
| 323 | $db->db_disconnect($dbh); | |
| 324 | ||
| 325 | if ($hb_pid) { | |
| 326 | kill('TERM', $hb_pid); | |
| 327 | waitpid($hb_pid, 0); | |
| 328 | } | |
| 329 | ||
| 330 | # If the browser already navigated away (Stop / tab close) | |
| 331 | # we can't redirect -- just exit. The user's already on a | |
| 332 | # different page. | |
| 333 | if ($aborted) { | |
| 334 | exit; | |
| 335 | } | |
| 336 | ||
| 337 | my $url; | |
| 338 | if ($final_result->{ok}) { | |
| 339 | # Pin more=0 so the destination page doesn't kick off a | |
| 340 | # second auto-pull loop. The spinner page already ran the | |
| 341 | # entire import in one shot. | |
| 342 | my $done = $running_done; | |
| 343 | my $total = $running_total; | |
| 344 | $total = $done if $total < $done; | |
| 345 | my $dups = $running_dups > 0 ? $running_dups : 0; | |
| 346 | $url = "/marketplaces.cgi?ok=imported&slug=$slug&done=$done&total=$total&dups=$dups&more=0"; | |
| 347 | } else { | |
| 348 | my $err = $final_result->{error} || 'unknown'; | |
| 349 | my $err_esc = $err; $err_esc =~ s/([^A-Za-z0-9 _:\-\.])/sprintf('%%%02X', ord($1))/ge; $err_esc =~ s/ /+/g; | |
| 350 | $url = "/marketplaces.cgi?imperr=$err_esc&slug=$slug"; | |
| 351 | } | |
| 352 | ||
| 353 | print qq~<script>window.location.replace(@{[ _js_str($url) ]});</script>\n~; | |
| 354 | print qq~<meta http-equiv="refresh" content="0; url=@{[ _attr_str($url) ]}">\n~; | |
| 355 | print "</body></html>\n"; | |
| 356 | exit; | |
| 357 | } | |
| 358 | else { | |
| 359 | $db->db_disconnect($dbh); | |
| 360 | print "Status: 302 Found\nLocation: /marketplaces.cgi?err=bad_action\n\n"; | |
| 361 | exit; | |
| 362 | } | |
| 363 | } | |
| 364 | ||
| 365 | # ---- GET render ----------------------------------------------------- | |
| 366 | my $dbh = $db->db_connect(); | |
| 367 | ||
| 368 | # One DB hit pulls every account row this user has; we look them up by | |
| 369 | # slug as we build the tile list. accounts_by_slug guards the missing- | |
| 370 | # table case internally so an unmigrated install renders cleanly. | |
| 371 | my $accounts = $mp->accounts_by_slug($db, $dbh, $DB, $uid); | |
| 372 | ||
| 373 | $db->db_disconnect($dbh); | |
| 374 | ||
| 375 | # Parse outcome flags from the POST-redirect-GET. | |
| 376 | my $ok_flag = $form->{ok} || ''; | |
| 377 | my $err_flag = $form->{err} || ''; | |
| 378 | my $imperr_flag = $form->{imperr} || ''; | |
| 379 | my $flag_slug = $form->{slug} || ''; | |
| 380 | my $import_n = $form->{n} || 0; $import_n =~ s/[^0-9]//g; $import_n ||= 0; | |
| 381 | my $import_sk = $form->{sk} || 0; $import_sk =~ s/[^0-9]//g; $import_sk ||= 0; | |
| 382 | my $import_done = $form->{done} || 0; $import_done =~ s/[^0-9]//g; $import_done ||= 0; | |
| 383 | my $import_total = $form->{total} || 0; $import_total =~ s/[^0-9]//g; $import_total ||= 0; | |
| 384 | my $import_dups = $form->{dups} || 0; $import_dups =~ s/[^0-9]//g; $import_dups ||= 0; | |
| 385 | my $import_more = $form->{more} || 0; $import_more =~ s/[^0-9]//g; $import_more ||= 0; | |
| 386 | $flag_slug = lc($flag_slug); $flag_slug =~ s/[^a-z0-9]//g; | |
| 387 | my $flag_name = ''; | |
| 388 | if (length $flag_slug) { | |
| 389 | my $p = $mp->by_slug($flag_slug); | |
| 390 | $flag_name = $p ? $p->{name} : ''; | |
| 391 | } | |
| 392 | ||
| 393 | # ---- Build per-platform tile data ----------------------------------- | |
| 394 | my @tiles; | |
| 395 | foreach my $p (@{ $mp->all }) { | |
| 396 | my $acc = $accounts->{ $p->{slug} }; | |
| 397 | my $state = ($acc && $acc->{status}) ? $acc->{status} : 'disconnected'; | |
| 398 | ||
| 399 | # Render credential-field rows for the connect modal. Driven by the | |
| 400 | # per-platform schema in Marketplaces.pm so adding a new platform | |
| 401 | # is a single change there -- nothing here needs to know about it. | |
| 402 | my $spec = $mp->cred_fields($p->{slug}) || []; | |
| 403 | my @cf; | |
| 404 | foreach my $f (@$spec) { | |
| 405 | # Pre-fill stored values so the Manage modal shows what's | |
| 406 | # already saved (so the user can see + edit, instead of staring | |
| 407 | # at an empty form that looks broken). Secret fields don't echo | |
| 408 | # the value back to the HTML -- we only flag "saved" and let | |
| 409 | # the user paste a replacement (or leave blank to keep). | |
| 410 | my $stored = ''; | |
| 411 | my $is_saved = 0; | |
| 412 | if ($acc && defined $acc->{$f->{name}}) { | |
| 413 | $stored = $acc->{$f->{name}}; | |
| 414 | $is_saved = length($stored) ? 1 : 0; | |
| 415 | } | |
| 416 | push @cf, { | |
| 417 | name => $f->{name}, | |
| 418 | label => $f->{label}, | |
| 419 | hint => $f->{hint} || '', | |
| 420 | type => $f->{type} || 'text', | |
| 421 | required => $f->{required} ? 1 : 0, | |
| 422 | secret => $f->{secret} ? 1 : 0, | |
| 423 | # Non-secret values are echoed back so the user can see what | |
| 424 | # they previously saved. Secret values stay server-side and | |
| 425 | # only the "saved" flag travels to the template. | |
| 426 | value => $f->{secret} ? '' : _h($stored), | |
| 427 | is_saved => $is_saved, | |
| 428 | }; | |
| 429 | } | |
| 430 | ||
| 431 | # Per-platform "Where to find these" intro shown at the top of the | |
| 432 | # connect/manage modal. Cults3D gets a specific multi-step flow | |
| 433 | # because their API-key generation is buried in account settings; | |
| 434 | # other platforms use a generic "sign in + grab from API settings" | |
| 435 | # line until we write platform-specific copy for each one. | |
| 436 | my $intro_text; | |
| 437 | if ($p->{slug} eq 'cults3d') { | |
| 438 | $intro_text = 'credentials come from <strong>Cults3D</strong> itself, not from WebSTLs. Go to ' | |
| 439 | . '<a href="https://cults3d.com" target="_blank" style="color:#a78bfa">cults3d.com</a>, ' | |
| 440 | . 'sign up or sign in, then go to <em>Settings</em> → <em>API</em> tab and generate an ' | |
| 441 | . 'API key by entering a name for the key — it can be anything, even ' | |
| 442 | . '<code>WebSTLs</code>. The name just helps you tell your keys apart later if you have ' | |
| 443 | . 'multiple ones to manage. They're stored on this account only.'; | |
| 444 | } elsif ($p->{slug} eq 'thangs') { | |
| 445 | $intro_text = '<strong>Manual paste-import only.</strong> Thangs sits behind Cloudflare bot protection, ' | |
| 446 | . 'so WebSTLs can't fetch your model list server-side. After you connect, the tile gets a ' | |
| 447 | . '<em>Paste your model URLs</em> button — open your ' | |
| 448 | . '<a href="https://thangs.com/designer/" target="_blank" style="color:#a78bfa">Thangs profile</a> ' | |
| 449 | . 'in a new tab, copy the URL of each model, and paste them in one per line. We create a draft ' | |
| 450 | . 'per URL. Just need your <strong>designer handle</strong> below so we can label the tile ' | |
| 451 | . '"Connected as <handle>" — no API key required (Thangs doesn't ' | |
| 452 | . 'publish a creator API; their push flow uses the ' | |
| 453 | . '<a href="https://thangs.com/sync" target="_blank" style="color:#a78bfa">Thangs Sync</a> ' | |
| 454 | . 'desktop app).'; | |
| 455 | } else { | |
| 456 | $intro_text = 'credentials come from <strong>' . _h($p->{name}) . '</strong> itself, not from WebSTLs. ' | |
| 457 | . 'Sign in at <a href="' . _h($p->{signup_url}) . '" target="_blank" rel="noopener" ' | |
| 458 | . 'style="color:#a78bfa">' . _h($p->{signup_url}) . '</a>, head to your developer / API ' | |
| 459 | . 'settings, and copy the values into the fields below. They're stored on this account only.'; | |
| 460 | } | |
| 461 | ||
| 462 | push @tiles, { | |
| 463 | slug => $p->{slug}, | |
| 464 | name => $p->{name}, | |
| 465 | tagline => $p->{tagline}, | |
| 466 | sells => $p->{sells}, | |
| 467 | auth => $p->{auth}, | |
| 468 | fees => $p->{fees}, | |
| 469 | signup_url => $p->{signup_url}, | |
| 470 | api_docs => $p->{api_docs}, | |
| 471 | icon => $p->{icon}, | |
| 472 | platform_status => $p->{status}, | |
| 473 | is_live => $p->{status} eq 'live' ? 1 : 0, | |
| 474 | is_manual => $p->{status} eq 'manual' ? 1 : 0, | |
| 475 | is_planned => $p->{status} eq 'planned' ? 1 : 0, | |
| 476 | is_connected => $state eq 'connected' ? 1 : 0, | |
| 477 | is_expired => $state eq 'expired' ? 1 : 0, | |
| 478 | is_error => $state eq 'error' ? 1 : 0, | |
| 479 | account_handle => ($acc && $acc->{account_handle}) || '', | |
| 480 | last_error => ($acc && $acc->{last_error}) || '', | |
| 481 | status_pill => _status_pill($p->{status}, $state), | |
| 482 | cred_fields => \@cf, | |
| 483 | has_cred_fields => scalar(@cf) ? 1 : 0, | |
| 484 | modal_id => 'mp-modal-' . $p->{slug}, | |
| 485 | import_supported => $mp->import_supported($p->{slug}), | |
| 486 | import_mode => $mp->import_mode($p->{slug}), | |
| 487 | is_import_auto => ($mp->import_mode($p->{slug}) eq 'auto') ? 1 : 0, | |
| 488 | is_import_manual => ($mp->import_mode($p->{slug}) eq 'manual') ? 1 : 0, | |
| 489 | intro_text => $intro_text, | |
| 490 | }; | |
| 491 | } | |
| 492 | ||
| 493 | my $tvars = { | |
| 494 | tile_count => scalar(@tiles), | |
| 495 | tiles => \@tiles, | |
| 496 | has_any_live => 1, | |
| 497 | ||
| 498 | # Outcome banner inputs (POST-redirect-GET feedback). | |
| 499 | flag_ok => $ok_flag, | |
| 500 | flag_err => $err_flag, | |
| 501 | flag_slug => _h($flag_slug), | |
| 502 | flag_name => _h($flag_name), | |
| 503 | show_ok => length($ok_flag) ? 1 : 0, | |
| 504 | show_err => length($err_flag) ? 1 : 0, | |
| 505 | show_imperr => length($imperr_flag) ? 1 : 0, | |
| 506 | flag_imperr => _h($imperr_flag), | |
| 507 | ok_connected => ($ok_flag eq 'connected') ? 1 : 0, | |
| 508 | ok_disconnected => ($ok_flag eq 'disconnected') ? 1 : 0, | |
| 509 | ok_imported => ($ok_flag eq 'imported') ? 1 : 0, | |
| 510 | import_n => $import_n, | |
| 511 | import_sk => $import_sk, | |
| 512 | import_has_skip => $import_sk ? 1 : 0, | |
| 513 | import_done => $import_done, | |
| 514 | import_total => $import_total, | |
| 515 | import_has_total => ($import_total > 0) ? 1 : 0, | |
| 516 | import_dups => $import_dups, | |
| 517 | import_has_dups => ($import_dups > 0) ? 1 : 0, | |
| 518 | import_more => $import_more, | |
| 519 | import_has_more => $import_more ? 1 : 0, | |
| 520 | }; | |
| 521 | ||
| 522 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 523 | ||
| 524 | my $body = join('', $tfile->template('webstls_marketplaces.html', $tvars, $userinfo)); | |
| 525 | ||
| 526 | $wrap->render({ | |
| 527 | userinfo => $userinfo, | |
| 528 | page_key => 'marketplaces', | |
| 529 | title => 'Marketplaces', | |
| 530 | body => $body, | |
| 531 | }); | |
| 532 | exit; | |
| 533 | ||
| 534 | sub _status_pill { | |
| 535 | my ($platform_status, $account_status) = @_; | |
| 536 | if ($platform_status eq 'manual') { | |
| 537 | return '<span class="pill" style="background:rgba(59,130,246,0.15);color:#60a5fa;border:1px solid rgba(59,130,246,0.35)">Manual export</span>'; | |
| 538 | } | |
| 539 | if ($account_status eq 'connected') { | |
| 540 | return '<span class="pill" style="background:rgba(34,197,94,0.18);color:#4ade80;border:1px solid rgba(34,197,94,0.40)">Connected</span>'; | |
| 541 | } | |
| 542 | if ($account_status eq 'expired') { | |
| 543 | return '<span class="pill" style="background:rgba(245,158,11,0.18);color:#fbbf24;border:1px solid rgba(245,158,11,0.40)">Token expired</span>'; | |
| 544 | } | |
| 545 | if ($account_status eq 'error') { | |
| 546 | return '<span class="pill" style="background:rgba(239,68,68,0.15);color:#f87171;border:1px solid rgba(239,68,68,0.40)">Error</span>'; | |
| 547 | } | |
| 548 | return '<span class="pill" style="background:rgba(124,58,237,0.10);color:#a78bfa;border:1px solid rgba(124,58,237,0.30)">Ready to connect</span>'; | |
| 549 | } | |
| 550 | ||
| 551 | #====================================================================== | |
| 552 | # marketplace_action entry: POST handler for cancel / retry on a | |
| 553 | # marketplace_pushes row. Owner-scoped. | |
| 554 | #====================================================================== | |
| 555 | sub _handle_action { | |
| 556 | my ($q, $form, $userinfo) = @_; | |
| 557 | ||
| 558 | my $uid = $userinfo->{user_id}; | |
| 559 | $uid =~ s/[^0-9]//g; | |
| 560 | ||
| 561 | my $act = lc($form->{act} || ''); | |
| 562 | $act =~ s/[^a-z_]//g; | |
| 563 | ||
| 564 | my $push_id = $form->{id} || 0; | |
| 565 | $push_id =~ s/[^0-9]//g; | |
| 566 | ||
| 567 | # Bail home if either is missing or invalid. | |
| 568 | if (!$act || !$push_id) { | |
| 569 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 570 | return; | |
| 571 | } | |
| 572 | ||
| 573 | my $dbh = $db->db_connect(); | |
| 574 | ||
| 575 | # Guard against missing table on a fresh DB. | |
| 576 | my $have_tbl = $db->db_readwrite($dbh, qq~ | |
| 577 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 578 | WHERE table_schema='$DB' AND table_name='marketplace_pushes' | |
| 579 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 580 | unless ($have_tbl && $have_tbl->{n}) { | |
| 581 | $db->db_disconnect($dbh); | |
| 582 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 583 | return; | |
| 584 | } | |
| 585 | ||
| 586 | # Verify ownership. The row must belong to the logged-in user. Admins | |
| 587 | # acting-as another user inherit that user's row visibility via the | |
| 588 | # impersonation swap in MODS::Login. | |
| 589 | my $row = $db->db_readwrite($dbh, qq~ | |
| 590 | SELECT id, user_id, status | |
| 591 | FROM `${DB}`.marketplace_pushes | |
| 592 | WHERE id='$push_id' AND user_id='$uid' | |
| 593 | LIMIT 1 | |
| 594 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 595 | ||
| 596 | unless ($row && $row->{id}) { | |
| 597 | $db->db_disconnect($dbh); | |
| 598 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 599 | return; | |
| 600 | } | |
| 601 | ||
| 602 | if ($act eq 'cancel') { | |
| 603 | # Only cancel pending pushes -- once status=success the listing is | |
| 604 | # live on the platform and "cancel" would not actually undo it. | |
| 605 | if (($row->{status} || '') eq 'pending') { | |
| 606 | $db->db_readwrite($dbh, qq~ | |
| 607 | DELETE FROM `${DB}`.marketplace_pushes | |
| 608 | WHERE id='$push_id' AND user_id='$uid' AND status='pending' | |
| 609 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 610 | } | |
| 611 | } | |
| 612 | elsif ($act eq 'retry') { | |
| 613 | # Retry: only valid on failed rows. Flip back to pending and the | |
| 614 | # worker picks it up on its next sweep. We do not touch the error_ | |
| 615 | # message column so the history of the prior failure is preserved | |
| 616 | # until the next attempt overwrites it. | |
| 617 | if (($row->{status} || '') eq 'failed') { | |
| 618 | $db->db_readwrite($dbh, qq~ | |
| 619 | UPDATE `${DB}`.marketplace_pushes | |
| 620 | SET status='pending' | |
| 621 | WHERE id='$push_id' AND user_id='$uid' AND status='failed' | |
| 622 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 623 | } | |
| 624 | } | |
| 625 | # Any other act value: no-op, just redirect home. | |
| 626 | ||
| 627 | $db->db_disconnect($dbh); | |
| 628 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 629 | } |