Diff -- /var/www/vhosts/webstls.com/httpdocs/marketplaces.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/marketplaces.cgi

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

Added
+629
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 0e62678c2bc3
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
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#======================================================================
22use strict;
23use warnings;
24
25use lib '/var/www/vhosts/webstls.com/httpdocs';
26use CGI;
27use MODS::Template;
28use MODS::DBConnect;
29use MODS::Login;
30use MODS::WebSTLs::Config;
31use MODS::WebSTLs::Wrapper;
32use MODS::WebSTLs::Marketplaces;
33use MODS::WebSTLs::ImportAdapters;
34
35my $q = CGI->new;
36my $form = $q->Vars;
37my $tfile = MODS::Template->new;
38my $db = MODS::DBConnect->new;
39my $auth = MODS::Login->new;
40my $config = MODS::WebSTLs::Config->new;
41my $wrap = MODS::WebSTLs::Wrapper->new;
42my $mp = MODS::WebSTLs::Marketplaces->new;
43my $imp = MODS::WebSTLs::ImportAdapters->new;
44my $DB = $config->settings('database_name');
45
46$|=1;
47
48my $userinfo = $auth->login_verify();
49if (!$userinfo) {
50 print "Status: 302 Found\nLocation: /login.cgi\n\n";
51 exit;
52}
53
54my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'marketplaces';
55
56if ($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
68require MODS::WebSTLs::Permissions;
69MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'manage_marketplaces');
70
71my $uid = $userinfo->{user_id};
72$uid =~ s/[^0-9]//g;
73
74sub _h { my $s = shift; $s = '' unless defined $s; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; return $s; }
75
76# Escape a string for inclusion as a JS string literal (single-quoted).
77sub _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.
85sub _attr_str {
86 my $s = shift; $s = '' unless defined $s;
87 $s =~ s/&/&amp;/g; $s =~ s/"/&quot;/g;
88 $s =~ s/</&lt;/g; $s =~ s/>/&gt;/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.
96my $is_post = (($ENV{REQUEST_METHOD} || '') eq 'POST') ? 1 : 0;
97if ($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&hellip;</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.
233window._impStopUrl = $initial_stop_url_js;
234window._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>
239HTML
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 -----------------------------------------------------
366my $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.
371my $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.
376my $ok_flag = $form->{ok} || '';
377my $err_flag = $form->{err} || '';
378my $imperr_flag = $form->{imperr} || '';
379my $flag_slug = $form->{slug} || '';
380my $import_n = $form->{n} || 0; $import_n =~ s/[^0-9]//g; $import_n ||= 0;
381my $import_sk = $form->{sk} || 0; $import_sk =~ s/[^0-9]//g; $import_sk ||= 0;
382my $import_done = $form->{done} || 0; $import_done =~ s/[^0-9]//g; $import_done ||= 0;
383my $import_total = $form->{total} || 0; $import_total =~ s/[^0-9]//g; $import_total ||= 0;
384my $import_dups = $form->{dups} || 0; $import_dups =~ s/[^0-9]//g; $import_dups ||= 0;
385my $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;
387my $flag_name = '';
388if (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 -----------------------------------
394my @tiles;
395foreach 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> &rarr; <em>API</em> tab and generate an '
441 . 'API key by entering a name for the key &mdash; 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&#39;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&#39;t fetch your model list server-side. After you connect, the tile gets a '
447 . '<em>Paste your model URLs</em> button &mdash; 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 . '&quot;Connected as &lt;handle&gt;&quot; &mdash; no API key required (Thangs doesn&#39;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&#39;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
493my $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
522print "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
524my $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});
532exit;
533
534sub _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#======================================================================
555sub _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}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help