Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/update_model_field.cgi
Diff
/var/www/vhosts/3dshawn.com/shop.3dshawn.com/update_model_field.cgi
added on local at 2026-07-01 22:10:07
Added
+225
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 2094e9117b93
to 2094e9117b93
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 | # ShopCart -- update a single field on a model row. | |
| 4 | # | |
| 5 | # POST params: | |
| 6 | # field = one of: title, price, tagline, description, | |
| 7 | # hero_image_url, hero_image_pos | |
| 8 | # value = new value (string) | |
| 9 | # model_id = which model to update | |
| 10 | # | |
| 11 | # Used by the in-place storefront editor for the FEATURED product | |
| 12 | # (model-level fields like title and price), as a sibling to | |
| 13 | # update_storefront_field.cgi which handles storefront-level fields. | |
| 14 | # | |
| 15 | # Authorization: the model must belong to the logged-in user | |
| 16 | # (models.user_id == userinfo.user_id). | |
| 17 | # | |
| 18 | # Returns JSON { success: 1 } or { success: 0, error: "..." }. | |
| 19 | #====================================================================== | |
| 20 | use strict; | |
| 21 | use warnings; | |
| 22 | ||
| 23 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 24 | use CGI; | |
| 25 | use MODS::DBConnect; | |
| 26 | use MODS::Login; | |
| 27 | use MODS::ShopCart::Config; | |
| 28 | ||
| 29 | $| = 1; | |
| 30 | ||
| 31 | my $q = CGI->new; | |
| 32 | my $db = MODS::DBConnect->new; | |
| 33 | my $auth = MODS::Login->new; | |
| 34 | my $config = MODS::ShopCart::Config->new; | |
| 35 | my $DB = $config->settings('database_name'); | |
| 36 | ||
| 37 | print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 38 | ||
| 39 | # DBConnect's error() helper calls exit() on a failing SQL call, which | |
| 40 | # bypasses any eval{} and leaves the response body empty. END block | |
| 41 | # emits a JSON message if that happens so the editor never gets a | |
| 42 | # silent 200/empty. | |
| 43 | our $SAVE_DONE = 0; | |
| 44 | END { | |
| 45 | if (!$SAVE_DONE) { | |
| 46 | print qq~{"success":0,"error":"server aborted mid-update -- check MODS/DB_ERRORS.log for the failing SQL and DBI message"}~; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | sub fail { | |
| 51 | my ($msg) = @_; | |
| 52 | $msg =~ s/"/\\"/g; | |
| 53 | print qq~{"success":0,"error":"$msg"}~; | |
| 54 | $SAVE_DONE = 1; | |
| 55 | exit; | |
| 56 | } | |
| 57 | ||
| 58 | my $userinfo = $auth->login_verify(); | |
| 59 | fail('not authenticated') unless $userinfo && $userinfo->{user_id}; | |
| 60 | require MODS::ShopCart::Permissions; | |
| 61 | fail('not authorized') unless MODS::ShopCart::Permissions->new->has_feature($userinfo, 'edit_models'); | |
| 62 | ||
| 63 | my $uid = $userinfo->{user_id}; | |
| 64 | $uid =~ s/[^0-9]//g; | |
| 65 | fail('bad user id') unless $uid; | |
| 66 | ||
| 67 | # Whitelist: client field name => SQL column. The "price" alias accepts | |
| 68 | # a user-typed dollar amount and we convert it to base_price_cents. | |
| 69 | my %ALLOWED = ( | |
| 70 | title => 'title', | |
| 71 | price => 'base_price_cents', | |
| 72 | tagline => 'tagline', | |
| 73 | description => 'description', | |
| 74 | category => 'category', | |
| 75 | license_type => 'license_type', | |
| 76 | status => 'status', | |
| 77 | visibility => 'visibility', | |
| 78 | hero_image_url => 'hero_image_url', | |
| 79 | hero_image_pos => 'hero_image_pos', | |
| 80 | ); | |
| 81 | ||
| 82 | my %MAX_LEN = ( | |
| 83 | title => 200, | |
| 84 | tagline => 180, | |
| 85 | description => 32000, | |
| 86 | category => 80, | |
| 87 | license_type => 80, | |
| 88 | hero_image_url => 255, | |
| 89 | hero_image_pos => 20, | |
| 90 | ); | |
| 91 | ||
| 92 | # Enum whitelists for fields that have constrained DB values. | |
| 93 | my %ENUM = ( | |
| 94 | status => { map { $_ => 1 } qw(draft published unlisted archived removed) }, | |
| 95 | visibility => { map { $_ => 1 } qw(public unlisted private) }, | |
| 96 | ); | |
| 97 | ||
| 98 | my $field = $q->param('field') || ''; | |
| 99 | fail('missing field') unless $field; | |
| 100 | fail("field '$field' is not editable") unless exists $ALLOWED{$field}; | |
| 101 | my $column = $ALLOWED{$field}; | |
| 102 | ||
| 103 | my $model_id = $q->param('model_id') || ''; | |
| 104 | $model_id =~ s/[^0-9]//g; | |
| 105 | fail('missing or bad model_id') unless $model_id; | |
| 106 | ||
| 107 | my $value = $q->param('value'); | |
| 108 | $value = '' unless defined $value; | |
| 109 | ||
| 110 | # Field-specific parsing. | |
| 111 | my $sql_value; | |
| 112 | if ($field eq 'price') { | |
| 113 | # Accept "$15", "15.00", "15.5" -> 1500 cents. Strip non-numeric | |
| 114 | # except for one decimal point. | |
| 115 | my $raw = $value; | |
| 116 | $raw =~ s/[^0-9.]//g; | |
| 117 | my $dollars = $raw + 0; | |
| 118 | if ($dollars < 0) { $dollars = 0; } | |
| 119 | my $cents = int($dollars * 100 + 0.5); | |
| 120 | $sql_value = "'$cents'"; | |
| 121 | } elsif ($ENUM{$field}) { | |
| 122 | # Constrained-value column. Reject anything not in the whitelist. | |
| 123 | my $v = lc($value); $v =~ s/[^a-z]//g; | |
| 124 | fail("invalid $field value") unless $ENUM{$field}{$v}; | |
| 125 | $sql_value = "'$v'"; | |
| 126 | } else { | |
| 127 | my $cap = $MAX_LEN{$field} || 255; | |
| 128 | $value = substr($value, 0, $cap) if length($value) > $cap; | |
| 129 | my $safe = $value; | |
| 130 | $safe =~ s/\\/\\\\/g; | |
| 131 | $safe =~ s/'/\\'/g; | |
| 132 | $sql_value = "'$safe'"; | |
| 133 | } | |
| 134 | ||
| 135 | # Verify the model belongs to this user before writing. Reject writes | |
| 136 | # to permanently-deleted (purged) models -- those are visible only to | |
| 137 | # admin tooling; the user can't reach them anymore. | |
| 138 | my $dbh = $db->db_connect(); | |
| 139 | my $owner = $db->db_readwrite($dbh, | |
| 140 | qq~SELECT user_id, purged_at FROM `${DB}`.models WHERE id='$model_id' LIMIT 1~, | |
| 141 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 142 | unless ($owner && $owner->{user_id} && $owner->{user_id} eq $uid) { | |
| 143 | $db->db_disconnect($dbh); | |
| 144 | fail('model not found or not yours'); | |
| 145 | } | |
| 146 | if ($owner->{purged_at}) { | |
| 147 | $db->db_disconnect($dbh); | |
| 148 | fail('model has been permanently deleted'); | |
| 149 | } | |
| 150 | ||
| 151 | # Wrap UPDATE so a missing column gets reported back via the END block. | |
| 152 | my $err; | |
| 153 | eval { | |
| 154 | $db->db_readwrite($dbh, qq~ | |
| 155 | UPDATE `${DB}`.models | |
| 156 | SET `$column` = $sql_value, updated_at = NOW() | |
| 157 | WHERE id = '$model_id' LIMIT 1 | |
| 158 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 159 | 1; | |
| 160 | } or do { $err = $@ || 'unknown SQL error'; }; | |
| 161 | ||
| 162 | # When the seller flips status to 'published' from this endpoint | |
| 163 | # (the My Listings inline PUBLISH button), we also have to insert a | |
| 164 | # storefront_listings row -- otherwise the model is "published" in | |
| 165 | # the DB but never shows on the seller's storefront because the | |
| 166 | # storefront query joins storefront_listings WHERE visible=1. | |
| 167 | # upload.cgi has the equivalent auto-list block for the form-driven | |
| 168 | # publish path; this mirrors it for the AJAX path so the two flows | |
| 169 | # stay in sync. INSERT IGNORE makes it safe to call repeatedly -- | |
| 170 | # the unique key on (storefront_id, model_id) absorbs duplicates, | |
| 171 | # so an already-listed model just gets a no-op. | |
| 172 | if (!$err && $field eq 'status' && $value && lc($value) eq 'published') { | |
| 173 | eval { | |
| 174 | my $sf = $db->db_readwrite($dbh, | |
| 175 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' LIMIT 1~, | |
| 176 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 177 | if ($sf && $sf->{id}) { | |
| 178 | my $sid = $sf->{id}; $sid =~ s/[^0-9]//g; | |
| 179 | # Put the newly-published model at the top of the | |
| 180 | # storefront order. Mirrors upload.cgi -- new published | |
| 181 | # work is what the seller wants the buyer to see first. | |
| 182 | my $top = $db->db_readwrite($dbh, qq~ | |
| 183 | SELECT COALESCE(MIN(sort_order), 0) - 1 AS new_top | |
| 184 | FROM `${DB}`.storefront_listings | |
| 185 | WHERE storefront_id='$sid' | |
| 186 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 187 | my $top_sort = ($top && defined $top->{new_top}) ? $top->{new_top} : 0; | |
| 188 | $db->db_readwrite($dbh, qq~ | |
| 189 | INSERT IGNORE INTO `${DB}`.storefront_listings SET | |
| 190 | storefront_id = '$sid', | |
| 191 | model_id = '$model_id', | |
| 192 | visible = 1, | |
| 193 | override_price_cents = NULL, | |
| 194 | sort_order = '$top_sort', | |
| 195 | added_at = NOW() | |
| 196 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 197 | } | |
| 198 | 1; | |
| 199 | } or do { | |
| 200 | # Auto-listing failed but the status update itself succeeded. | |
| 201 | # Don't surface the secondary failure -- the seller can still | |
| 202 | # toggle visibility manually from My Listings. Just log via | |
| 203 | # the END block path so we know what happened. | |
| 204 | $err = "status updated but auto-list failed: " . ($@ || 'unknown'); | |
| 205 | }; | |
| 206 | } | |
| 207 | ||
| 208 | $db->db_disconnect($dbh); | |
| 209 | ||
| 210 | if ($err) { | |
| 211 | my $msg = $err; | |
| 212 | $msg =~ s/[\r\n]+/ /g; | |
| 213 | $msg =~ s/"/\\"/g; | |
| 214 | my $hint = ''; | |
| 215 | if ($msg =~ /Unknown column/i) { | |
| 216 | $hint = " (the column may not exist yet -- run the models ALTER from installation_instructions.html)"; | |
| 217 | } | |
| 218 | print qq~{"success":0,"error":"SQL error: $msg$hint"}~; | |
| 219 | $SAVE_DONE = 1; | |
| 220 | exit; | |
| 221 | } | |
| 222 | ||
| 223 | print qq~{"success":1,"field":"$field","model_id":"$model_id"}~; | |
| 224 | $SAVE_DONE = 1; | |
| 225 | exit; |