added on local at 2026-07-01 21:46:56
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- REST API dispatcher. | |
| 4 | # | |
| 5 | # Auth: Authorization: Bearer rpc_live_<48 hex> | |
| 6 | # Format: JSON in / JSON out, UTF-8. | |
| 7 | # | |
| 8 | # Routing (path comes from PATH_INFO): | |
| 9 | # GET /api/v1/me | |
| 10 | # GET /api/v1/products?limit=100&offset=0&marketplace=amazon | |
| 11 | # GET /api/v1/products/123 | |
| 12 | # PATCH /api/v1/products/123 { reprice_enabled: 0|1, min_price_cents: N, max_price_cents: N } | |
| 13 | # GET /api/v1/rules | |
| 14 | # GET /api/v1/price_changes?limit=200 | |
| 15 | # | |
| 16 | # Studio-tier only. Bearer tokens come from /profile.cgi. | |
| 17 | #====================================================================== | |
| 18 | use strict; | |
| 19 | use warnings; | |
| 20 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 21 | use CGI; | |
| 22 | use JSON::PP; | |
| 23 | use Digest::SHA qw(sha256_hex); | |
| 24 | use MODS::DBConnect; | |
| 25 | use MODS::RePricer::Config; | |
| 26 | ||
| 27 | $|=1; | |
| 28 | ||
| 29 | my $q = CGI->new; | |
| 30 | my $db = MODS::DBConnect->new; | |
| 31 | my $cfg = MODS::RePricer::Config->new; | |
| 32 | my $DB = $cfg->settings('database_name'); | |
| 33 | ||
| 34 | sub send_json { | |
| 35 | my ($status, $payload) = @_; | |
| 36 | my $body = JSON::PP->new->utf8->canonical->encode($payload); | |
| 37 | print "Status: $status\n"; | |
| 38 | print "Content-Type: application/json; charset=utf-8\n"; | |
| 39 | print "Cache-Control: no-store\n"; | |
| 40 | print "Access-Control-Allow-Origin: *\n"; | |
| 41 | print "\n"; | |
| 42 | print $body; | |
| 43 | exit; | |
| 44 | } | |
| 45 | ||
| 46 | sub err { send_json($_[0], { error => $_[1] }); } | |
| 47 | ||
| 48 | # ---- auth: Authorization header -------------------------------------- | |
| 49 | my $auth_hdr = $ENV{HTTP_AUTHORIZATION} || ''; | |
| 50 | my ($bearer) = $auth_hdr =~ /^Bearer\s+(rpc_live_[a-f0-9]{48})\s*$/; | |
| 51 | err(401, 'missing or malformed Authorization header') unless $bearer; | |
| 52 | ||
| 53 | my $h = sha256_hex($bearer); | |
| 54 | my $dbh = $db->db_connect(); | |
| 55 | my $key = $db->db_readwrite($dbh, qq~ | |
| 56 | SELECT k.id AS key_id, k.user_id, u.plan_tier, u.account_status | |
| 57 | FROM `${DB}`.api_keys k | |
| 58 | JOIN `${DB}`.users u ON u.id = k.user_id | |
| 59 | WHERE k.key_hash='$h' AND k.revoked_at IS NULL | |
| 60 | LIMIT 1 | |
| 61 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 62 | ||
| 63 | err(401, 'invalid api key') unless $key && $key->{key_id}; | |
| 64 | err(403, 'studio tier required') unless ($key->{plan_tier} || '') eq 'studio'; | |
| 65 | err(403, 'account not active') unless ($key->{account_status} || 'active') eq 'active'; | |
| 66 | ||
| 67 | my $uid = $key->{user_id}; $uid =~ s/[^0-9]//g; | |
| 68 | my $key_id = $key->{key_id}; $key_id =~ s/[^0-9]//g; | |
| 69 | ||
| 70 | # Stamp last_used_at -- fire-and-forget. | |
| 71 | $db->db_readwrite($dbh, qq~ | |
| 72 | UPDATE `${DB}`.api_keys SET last_used_at=NOW() WHERE id='$key_id' | |
| 73 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 74 | ||
| 75 | # ---- route ---------------------------------------------------------- | |
| 76 | my $path = $ENV{PATH_INFO} || ''; | |
| 77 | $path =~ s{^/+}{}; # strip leading slashes | |
| 78 | $path =~ s{/+$}{}; # strip trailing slashes | |
| 79 | $path =~ s{^api/v1/?}{}; # strip /api/v1 prefix if present | |
| 80 | my @seg = split m{/}, $path; | |
| 81 | ||
| 82 | my $method = $ENV{REQUEST_METHOD} || 'GET'; | |
| 83 | ||
| 84 | sub _q { my $s = shift // ''; $s =~ s/'/''/g; $s } | |
| 85 | ||
| 86 | # helper: safe integer arg | |
| 87 | sub _int { my $v = shift // 0; $v =~ s/[^0-9]//g; $v eq '' ? 0 : int($v) } | |
| 88 | ||
| 89 | # helper: read JSON body | |
| 90 | sub _read_body { | |
| 91 | my $len = $ENV{CONTENT_LENGTH} || 0; | |
| 92 | return {} unless $len > 0 && $len < 1_000_000; | |
| 93 | my $raw; read(STDIN, $raw, $len); | |
| 94 | my $j = eval { JSON::PP->new->utf8->decode($raw) }; | |
| 95 | return ref($j) eq 'HASH' ? $j : {}; | |
| 96 | } | |
| 97 | ||
| 98 | # ---- GET /api/v1/me ------------------------------------------------ | |
| 99 | if (@seg == 1 && $seg[0] eq 'me' && $method eq 'GET') { | |
| 100 | my $u = $db->db_readwrite($dbh, qq~ | |
| 101 | SELECT id, email, display_name, plan_tier, account_status, | |
| 102 | default_currency, timezone, created_at | |
| 103 | FROM `${DB}`.users WHERE id='$uid' | |
| 104 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 105 | send_json(200, { user => $u }); | |
| 106 | } | |
| 107 | ||
| 108 | # ---- GET /api/v1/products ------------------------------------------ | |
| 109 | elsif (@seg == 1 && $seg[0] eq 'products' && $method eq 'GET') { | |
| 110 | my $f = $q->Vars; | |
| 111 | my $limit = _int($f->{limit} || 100); $limit = 500 if $limit > 500; $limit = 1 if $limit < 1; | |
| 112 | my $offset = _int($f->{offset} || 0); | |
| 113 | my $where = "user_id='$uid'"; | |
| 114 | if (my $mp = $f->{marketplace}) { | |
| 115 | $mp =~ s/[^a-z]//g; | |
| 116 | $where .= " AND marketplace='$mp'" if $mp; | |
| 117 | } | |
| 118 | my $rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 119 | SELECT id, marketplace, sku, asin, walmart_item_id, title, | |
| 120 | current_price_cents, cost_cents, min_price_cents, | |
| 121 | max_price_cents, map_cents, inventory_qty, | |
| 122 | reprice_enabled, status, created_at | |
| 123 | FROM `${DB}`.products | |
| 124 | WHERE $where | |
| 125 | ORDER BY id DESC | |
| 126 | LIMIT $limit OFFSET $offset | |
| 127 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 128 | send_json(200, { products => $rows, count => scalar(@$rows), limit => $limit, offset => $offset }); | |
| 129 | } | |
| 130 | ||
| 131 | # ---- GET /api/v1/products/N ---------------------------------------- | |
| 132 | elsif (@seg == 2 && $seg[0] eq 'products' && $method eq 'GET') { | |
| 133 | my $pid = _int($seg[1]); | |
| 134 | my $row = $db->db_readwrite($dbh, qq~ | |
| 135 | SELECT * FROM `${DB}`.products WHERE id='$pid' AND user_id='$uid' | |
| 136 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 137 | err(404, 'not found') unless $row && $row->{id}; | |
| 138 | send_json(200, { product => $row }); | |
| 139 | } | |
| 140 | ||
| 141 | # ---- PATCH /api/v1/products/N -------------------------------------- | |
| 142 | elsif (@seg == 2 && $seg[0] eq 'products' && ($method eq 'PATCH' || $method eq 'POST')) { | |
| 143 | my $pid = _int($seg[1]); | |
| 144 | my $own = $db->db_readwrite($dbh, qq~ | |
| 145 | SELECT id FROM `${DB}`.products WHERE id='$pid' AND user_id='$uid' | |
| 146 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 147 | err(404, 'not found') unless $own && $own->{id}; | |
| 148 | my $b = _read_body(); | |
| 149 | my @set; | |
| 150 | if (exists $b->{reprice_enabled}) { | |
| 151 | push @set, sprintf("reprice_enabled='%d'", $b->{reprice_enabled} ? 1 : 0); | |
| 152 | } | |
| 153 | foreach my $col (qw(min_price_cents max_price_cents cost_cents map_cents inventory_qty)) { | |
| 154 | if (exists $b->{$col}) { | |
| 155 | push @set, sprintf("%s='%d'", $col, _int($b->{$col})); | |
| 156 | } | |
| 157 | } | |
| 158 | err(400, 'no updatable fields supplied') unless @set; | |
| 159 | my $set = join(', ', @set); | |
| 160 | $db->db_readwrite($dbh, qq~ | |
| 161 | UPDATE `${DB}`.products SET $set WHERE id='$pid' AND user_id='$uid' | |
| 162 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 163 | my $row = $db->db_readwrite($dbh, qq~ | |
| 164 | SELECT * FROM `${DB}`.products WHERE id='$pid' | |
| 165 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 166 | send_json(200, { product => $row }); | |
| 167 | } | |
| 168 | ||
| 169 | # ---- GET /api/v1/rules --------------------------------------------- | |
| 170 | elsif (@seg == 1 && $seg[0] eq 'rules' && $method eq 'GET') { | |
| 171 | my $rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 172 | SELECT * FROM `${DB}`.repricing_rules WHERE user_id='$uid' ORDER BY id DESC | |
| 173 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 174 | send_json(200, { rules => $rows, count => scalar(@$rows) }); | |
| 175 | } | |
| 176 | ||
| 177 | # ---- GET /api/v1/price_changes ------------------------------------- | |
| 178 | elsif (@seg == 1 && $seg[0] eq 'price_changes' && $method eq 'GET') { | |
| 179 | my $f = $q->Vars; | |
| 180 | my $limit = _int($f->{limit} || 200); $limit = 1000 if $limit > 1000; $limit = 1 if $limit < 1; | |
| 181 | my $rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 182 | SELECT id, product_id, old_price_cents, new_price_cents, reason, | |
| 183 | sync_status, automated, created_at | |
| 184 | FROM `${DB}`.price_changes | |
| 185 | WHERE user_id='$uid' | |
| 186 | ORDER BY id DESC | |
| 187 | LIMIT $limit | |
| 188 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 189 | send_json(200, { price_changes => $rows, count => scalar(@$rows), limit => $limit }); | |
| 190 | } | |
| 191 | ||
| 192 | # ---- fallthrough --------------------------------------------------- | |
| 193 | else { | |
| 194 | err(404, "no route: $method /$path"); | |
| 195 | } |