added on local at 2026-07-01 21:46:51
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- GDPR data export. | |
| 4 | # | |
| 5 | # GET /account_export.cgi confirmation + previous exports | |
| 6 | # POST act=request enqueue an export job | |
| 7 | # GET /account_export.cgi?dl=ID stream the JSON blob (auth scoped) | |
| 8 | # | |
| 9 | # Generates the file inline (synchronous -- a few MB at most for typical | |
| 10 | # accounts). For huge users we'd queue + run from the notifications | |
| 11 | # worker, but that's a future scale problem. | |
| 12 | #====================================================================== | |
| 13 | use strict; | |
| 14 | use warnings; | |
| 15 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 16 | use CGI; | |
| 17 | use JSON::PP; | |
| 18 | use MODS::DBConnect; | |
| 19 | use MODS::Login; | |
| 20 | use MODS::RePricer::Config; | |
| 21 | use MODS::RePricer::Wrapper; | |
| 22 | ||
| 23 | $|=1; | |
| 24 | my $q = CGI->new; | |
| 25 | my $form = $q->Vars; | |
| 26 | my $auth = MODS::Login->new; | |
| 27 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 28 | my $db = MODS::DBConnect->new; | |
| 29 | my $cfg = MODS::RePricer::Config->new; | |
| 30 | my $DB = $cfg->settings('database_name'); | |
| 31 | ||
| 32 | my $userinfo = $auth->login_verify(); | |
| 33 | unless ($userinfo) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 34 | my $uid = $userinfo->{user_id}; $uid =~ s/[^0-9]//g; | |
| 35 | ||
| 36 | sub _h { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; $s } | |
| 37 | ||
| 38 | my $dbh = $db->db_connect(); | |
| 39 | ||
| 40 | if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{act} || '') eq 'request') { | |
| 41 | # Build the export inline (synchronous). Strips secrets (password hash, | |
| 42 | # encrypted tokens, 2FA secret) -- those have no use to the user. | |
| 43 | my $u = $db->db_readwrite($dbh, qq~ | |
| 44 | SELECT id, email, display_name, plan_tier, account_status, | |
| 45 | default_currency, timezone, is_admin, | |
| 46 | created_at, last_login_at, | |
| 47 | two_factor_enabled, | |
| 48 | notify_floor_hit, notify_buybox_lost, notify_weekly_summary, | |
| 49 | tax_mode, tax_country, tax_id_number, | |
| 50 | account_deletion_requested_at | |
| 51 | FROM `${DB}`.users WHERE id='$uid' | |
| 52 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 53 | ||
| 54 | my $accounts = $db->db_readwrite_multiple($dbh, qq~ | |
| 55 | SELECT id, platform, status, account_handle, metadata, | |
| 56 | connected_at, last_used_at, last_error | |
| 57 | FROM `${DB}`.marketplace_accounts WHERE user_id='$uid' | |
| 58 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 59 | ||
| 60 | my $products = $db->db_readwrite_multiple($dbh, qq~ | |
| 61 | SELECT id, marketplace, sku, asin, walmart_item_id, title, | |
| 62 | current_price_cents, cost_cents, min_price_cents, | |
| 63 | max_price_cents, map_cents, inventory_qty, | |
| 64 | reprice_enabled, status, created_at | |
| 65 | FROM `${DB}`.products WHERE user_id='$uid' | |
| 66 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 67 | ||
| 68 | my $rules = $db->db_readwrite_multiple($dbh, qq~ | |
| 69 | SELECT * FROM `${DB}`.repricing_rules WHERE user_id='$uid' | |
| 70 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 71 | ||
| 72 | my $changes = $db->db_readwrite_multiple($dbh, qq~ | |
| 73 | SELECT id, product_id, old_price_cents, new_price_cents, reason, | |
| 74 | sync_status, automated, created_at | |
| 75 | FROM `${DB}`.price_changes WHERE user_id='$uid' | |
| 76 | ORDER BY id DESC LIMIT 5000 | |
| 77 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 78 | ||
| 79 | my $subs = $db->db_readwrite_multiple($dbh, qq~ | |
| 80 | SELECT id, plan_id, cadence, status, current_period_end | |
| 81 | FROM `${DB}`.subscriptions WHERE user_id='$uid' | |
| 82 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 83 | ||
| 84 | my $invoices = $db->db_readwrite_multiple($dbh, qq~ | |
| 85 | SELECT id, invoice_number, amount_due_cents, status, issued_at | |
| 86 | FROM `${DB}`.invoices WHERE user_id='$uid' | |
| 87 | ORDER BY id DESC | |
| 88 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 89 | ||
| 90 | $db->db_disconnect($dbh); | |
| 91 | ||
| 92 | my $dump = { | |
| 93 | export_generated_at => scalar(gmtime) . ' UTC', | |
| 94 | format_version => 1, | |
| 95 | user => $u, | |
| 96 | marketplace_accounts => $accounts, | |
| 97 | products => $products, | |
| 98 | repricing_rules => $rules, | |
| 99 | price_changes => $changes, | |
| 100 | subscriptions => $subs, | |
| 101 | invoices => $invoices, | |
| 102 | notice => 'Secrets (password hash, marketplace OAuth tokens, ' | |
| 103 | . '2FA secret) and Stripe internal IDs are intentionally ' | |
| 104 | . 'omitted -- they have no use to you outside RePricer and ' | |
| 105 | . 'would be a security risk if leaked.', | |
| 106 | }; | |
| 107 | my $json = JSON::PP->new->utf8->canonical->pretty->encode($dump); | |
| 108 | my @t = gmtime; | |
| 109 | my $fname = sprintf('repricer-export-%d-%04d%02d%02d.json', | |
| 110 | $uid, $t[5]+1900, $t[4]+1, $t[3]); | |
| 111 | print "Content-Type: application/json\n"; | |
| 112 | print "Content-Disposition: attachment; filename=\"$fname\"\n"; | |
| 113 | print "Cache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 114 | print $json; | |
| 115 | exit; | |
| 116 | } | |
| 117 | ||
| 118 | $db->db_disconnect($dbh); | |
| 119 | ||
| 120 | my $body = '<div style="max-width:680px;margin:0 auto;padding:24px 28px;line-height:1.7;color:#e6ecf6">'; | |
| 121 | $body .= '<h1 style="font-size:26px;color:#fff;margin:0 0 6px">Download your data</h1>'; | |
| 122 | $body .= '<p style="color:#7e92b6">Per <a href="/privacy.cgi" style="color:#14b8a6">our privacy policy</a> and GDPR Article 20, you can download a full machine-readable JSON dump of everything we have on you, any time.</p>'; | |
| 123 | $body .= '<p style="color:#7e92b6">The export includes:</p>'; | |
| 124 | $body .= '<ul style="color:#e6ecf6">'; | |
| 125 | $body .= ' <li>Your user profile + preferences</li>'; | |
| 126 | $body .= ' <li>Marketplace account list (handles, status; <em>not</em> the OAuth tokens for security)</li>'; | |
| 127 | $body .= ' <li>Your product catalog (SKU, title, prices, floors, ceilings)</li>'; | |
| 128 | $body .= ' <li>Your repricing rules</li>'; | |
| 129 | $body .= ' <li>Last 5,000 price changes</li>'; | |
| 130 | $body .= ' <li>Subscription + invoice history (without card details)</li>'; | |
| 131 | $body .= '</ul>'; | |
| 132 | $body .= '<form method="POST" style="margin-top:18px">'; | |
| 133 | $body .= ' <input type="hidden" name="act" value="request">'; | |
| 134 | $body .= ' <button type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:10px 22px;border-radius:8px;font-weight:600;cursor:pointer">Download JSON now</button>'; | |
| 135 | $body .= '</form>'; | |
| 136 | $body .= '<div style="margin-top:24px;color:#7e92b6;font-size:12px">For CSV exports per data category, see the buttons on <a href="/products.cgi" style="color:#14b8a6">/products.cgi</a> and the per-category download links.</div>'; | |
| 137 | $body .= '</div>'; | |
| 138 | ||
| 139 | 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"; | |
| 140 | $wrap->render({ userinfo => $userinfo, title => 'Download your data', body => $body }); | |
| 141 | exit; |