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

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

added on WebSTLs (webstls.com) at 2026-07-01 22:27:09

Added
+558
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 5e75f1129ea8
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 -- JSON micro-endpoints for in-place editors.
4#
5# One CGI, three entry points (dispatched on SCRIPT_NAME):
6# /update_model_field.cgi -- update a single field on a models row
7# /update_storefront_field.cgi -- update a single field on a storefronts row
8# /update_listing.cgi -- update a storefront_listings row (action-based)
9#
10# The .cgi files for the other two entries are 3-line wrappers that
11# `do` this script. All three share the same JSON response shape, the
12# same END-block safety net, and the same auth pattern.
13#
14# Response shape:
15# { "success": 1, ... } on success
16# { "success": 0, "error": ... } on failure
17#======================================================================
18use strict;
19use warnings;
20
21use lib '/var/www/vhosts/webstls.com/httpdocs';
22use CGI;
23use MODS::DBConnect;
24use MODS::Login;
25use MODS::WebSTLs::Config;
26
27$| = 1;
28
29my $q = CGI->new;
30my $db = MODS::DBConnect->new;
31my $auth = MODS::Login->new;
32my $config = MODS::WebSTLs::Config->new;
33my $DB = $config->settings('database_name');
34
35print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
36
37# DBConnect's error() helper calls exit() on a failing SQL call, which
38# bypasses any eval{} and leaves the response body empty. END block
39# emits a JSON message if that happens so the editor never gets a
40# silent 200/empty.
41our $SAVE_DONE = 0;
42END {
43 if (!$SAVE_DONE) {
44 print qq~{"success":0,"error":"server aborted mid-update -- check MODS/DB_ERRORS.log for the failing SQL and DBI message"}~;
45 }
46}
47
48sub _upd_fail {
49 my ($msg) = @_;
50 $msg =~ s/"/\\"/g;
51 print qq~{"success":0,"error":"$msg"}~;
52 $SAVE_DONE = 1;
53 exit;
54}
55
56my $userinfo = $auth->login_verify();
57_upd_fail('not authenticated') unless $userinfo && $userinfo->{user_id};
58require MODS::WebSTLs::Permissions;
59
60my $uid = $userinfo->{user_id};
61$uid =~ s/[^0-9]//g;
62_upd_fail('bad user id') unless $uid;
63
64# Dispatch on SCRIPT_NAME. Each entry gates on the appropriate
65# permission feature *inside* its handler so we don't reject a caller
66# that hits us via the "wrong" script name accidentally.
67my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'update_model_field';
68
69if ($entry eq 'update_storefront_field') { _upd_handle_storefront_field(); }
70elsif ($entry eq 'update_listing') { _upd_handle_listing(); }
71else { _upd_handle_model_field(); }
72exit;
73
74#======================================================================
75# update_model_field entry: update a single field on a models row.
76#
77# POST params:
78# field = one of: title, price, tagline, description,
79# hero_image_url, hero_image_pos, category, license_type,
80# status, visibility
81# value = new value (string)
82# model_id = which model to update
83#
84# Used by the in-place storefront editor for the FEATURED product
85# (model-level fields like title and price). Authorization: the model
86# must belong to the logged-in user.
87#======================================================================
88sub _upd_handle_model_field {
89 _upd_fail('not authorized') unless MODS::WebSTLs::Permissions->new->has_feature($userinfo, 'edit_models');
90
91 # Whitelist: client field name => SQL column. The "price" alias accepts
92 # a user-typed dollar amount and we convert it to base_price_cents.
93 my %ALLOWED = (
94 title => 'title',
95 price => 'base_price_cents',
96 tagline => 'tagline',
97 description => 'description',
98 category => 'category',
99 license_type => 'license_type',
100 status => 'status',
101 visibility => 'visibility',
102 hero_image_url => 'hero_image_url',
103 hero_image_pos => 'hero_image_pos',
104 );
105
106 my %MAX_LEN = (
107 title => 200,
108 tagline => 180,
109 description => 32000,
110 category => 80,
111 license_type => 80,
112 hero_image_url => 255,
113 hero_image_pos => 20,
114 );
115
116 # Enum whitelists for fields that have constrained DB values.
117 my %ENUM = (
118 status => { map { $_ => 1 } qw(draft published unlisted archived removed) },
119 visibility => { map { $_ => 1 } qw(public unlisted private) },
120 );
121
122 my $field = $q->param('field') || '';
123 _upd_fail('missing field') unless $field;
124 _upd_fail("field '$field' is not editable") unless exists $ALLOWED{$field};
125 my $column = $ALLOWED{$field};
126
127 my $model_id = $q->param('model_id') || '';
128 $model_id =~ s/[^0-9]//g;
129 _upd_fail('missing or bad model_id') unless $model_id;
130
131 my $value = $q->param('value');
132 $value = '' unless defined $value;
133
134 # Field-specific parsing.
135 my $sql_value;
136 if ($field eq 'price') {
137 # Accept "$15", "15.00", "15.5" -> 1500 cents. Strip non-numeric
138 # except for one decimal point.
139 my $raw = $value;
140 $raw =~ s/[^0-9.]//g;
141 my $dollars = $raw + 0;
142 if ($dollars < 0) { $dollars = 0; }
143 my $cents = int($dollars * 100 + 0.5);
144 $sql_value = "'$cents'";
145 } elsif ($ENUM{$field}) {
146 # Constrained-value column. Reject anything not in the whitelist.
147 my $v = lc($value); $v =~ s/[^a-z]//g;
148 _upd_fail("invalid $field value") unless $ENUM{$field}{$v};
149 $sql_value = "'$v'";
150 } else {
151 my $cap = $MAX_LEN{$field} || 255;
152 $value = substr($value, 0, $cap) if length($value) > $cap;
153 my $safe = $value;
154 $safe =~ s/\\/\\\\/g;
155 $safe =~ s/'/\\'/g;
156 $sql_value = "'$safe'";
157 }
158
159 # Verify the model belongs to this user before writing. Reject writes
160 # to permanently-deleted (purged) models -- those are visible only to
161 # admin tooling; the user can't reach them anymore.
162 my $dbh = $db->db_connect();
163 my $owner = $db->db_readwrite($dbh,
164 qq~SELECT user_id, purged_at FROM `${DB}`.models WHERE id='$model_id' LIMIT 1~,
165 $ENV{SCRIPT_NAME}, __LINE__);
166 unless ($owner && $owner->{user_id} && $owner->{user_id} eq $uid) {
167 $db->db_disconnect($dbh);
168 _upd_fail('model not found or not yours');
169 }
170 if ($owner->{purged_at}) {
171 $db->db_disconnect($dbh);
172 _upd_fail('model has been permanently deleted');
173 }
174
175 # Wrap UPDATE so a missing column gets reported back via the END block.
176 my $err;
177 eval {
178 $db->db_readwrite($dbh, qq~
179 UPDATE `${DB}`.models
180 SET `$column` = $sql_value, updated_at = NOW()
181 WHERE id = '$model_id' LIMIT 1
182 ~, $ENV{SCRIPT_NAME}, __LINE__);
183 1;
184 } or do { $err = $@ || 'unknown SQL error'; };
185
186 # When the seller flips status to 'published' from this endpoint
187 # (the My Listings inline PUBLISH button), we also have to insert a
188 # storefront_listings row -- otherwise the model is "published" in
189 # the DB but never shows on the seller's storefront because the
190 # storefront query joins storefront_listings WHERE visible=1.
191 # upload.cgi has the equivalent auto-list block for the form-driven
192 # publish path; this mirrors it for the AJAX path so the two flows
193 # stay in sync. INSERT IGNORE makes it safe to call repeatedly --
194 # the unique key on (storefront_id, model_id) absorbs duplicates,
195 # so an already-listed model just gets a no-op.
196 if (!$err && $field eq 'status' && $value && lc($value) eq 'published') {
197 eval {
198 my $sf = $db->db_readwrite($dbh,
199 qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' LIMIT 1~,
200 $ENV{SCRIPT_NAME}, __LINE__);
201 if ($sf && $sf->{id}) {
202 my $sid = $sf->{id}; $sid =~ s/[^0-9]//g;
203 # Put the newly-published model at the top of the
204 # storefront order. Mirrors upload.cgi -- new published
205 # work is what the seller wants the buyer to see first.
206 my $top = $db->db_readwrite($dbh, qq~
207 SELECT COALESCE(MIN(sort_order), 0) - 1 AS new_top
208 FROM `${DB}`.storefront_listings
209 WHERE storefront_id='$sid'
210 ~, $ENV{SCRIPT_NAME}, __LINE__);
211 my $top_sort = ($top && defined $top->{new_top}) ? $top->{new_top} : 0;
212 $db->db_readwrite($dbh, qq~
213 INSERT IGNORE INTO `${DB}`.storefront_listings SET
214 storefront_id = '$sid',
215 model_id = '$model_id',
216 visible = 1,
217 override_price_cents = NULL,
218 sort_order = '$top_sort',
219 added_at = NOW()
220 ~, $ENV{SCRIPT_NAME}, __LINE__);
221 }
222 1;
223 } or do {
224 # Auto-listing failed but the status update itself succeeded.
225 # Don't surface the secondary failure -- the seller can still
226 # toggle visibility manually from My Listings. Just log via
227 # the END block path so we know what happened.
228 $err = "status updated but auto-list failed: " . ($@ || 'unknown');
229 };
230 }
231
232 $db->db_disconnect($dbh);
233
234 if ($err) {
235 my $msg = $err;
236 $msg =~ s/[\r\n]+/ /g;
237 $msg =~ s/"/\\"/g;
238 my $hint = '';
239 if ($msg =~ /Unknown column/i) {
240 $hint = " (the column may not exist yet -- run the models ALTER from installation_instructions.html)";
241 }
242 print qq~{"success":0,"error":"SQL error: $msg$hint"}~;
243 $SAVE_DONE = 1;
244 return;
245 }
246
247 print qq~{"success":1,"field":"$field","model_id":"$model_id"}~;
248 $SAVE_DONE = 1;
249 return;
250}
251
252#======================================================================
253# update_storefront_field entry: update a single storefront field.
254#
255# POST params:
256# field = one of: name, tagline, about_text, hero_image_1/2/3, ...
257# value = new value (string)
258#
259# Used by the page editor's in-place WYSIWYG. On blur of an editable
260# element (or after an image is uploaded and selected) the iframe POSTs
261# here. We auth the user, verify they own the storefront, whitelist
262# the field, and UPDATE the row.
263#======================================================================
264sub _upd_handle_storefront_field {
265 _upd_fail('not authorized') unless MODS::WebSTLs::Permissions->new->has_feature($userinfo, 'edit_storefront');
266
267 # Whitelist of fields that can be updated through this endpoint.
268 # Keys are the public field name (what the client sends); the value
269 # is the SQL column to write (defense against column-injection even
270 # though we sanitize separately).
271 my %ALLOWED = (
272 name => 'name',
273 tagline => 'tagline',
274 about_text => 'about_text',
275 hero_image_1 => 'hero_image_1',
276 hero_image_2 => 'hero_image_2',
277 hero_image_3 => 'hero_image_3',
278 hero_image_1_pos => 'hero_image_1_pos',
279 hero_image_2_pos => 'hero_image_2_pos',
280 hero_image_3_pos => 'hero_image_3_pos',
281 cta_primary_label => 'cta_primary_label',
282 cta_secondary_label => 'cta_secondary_label',
283 featured_badge_label => 'featured_badge_label',
284 section_heading => 'section_heading',
285 footer_byline => 'footer_byline',
286 featured_title => 'featured_title',
287 featured_price => 'featured_price',
288 featured_hero_image => 'featured_hero_image',
289 featured_hero_pos => 'featured_hero_pos',
290 # Catalog-layout hero chrome -- added with the
291 # "edit everything on the page" pass. Schema-guarded in store.cgi
292 # (NULL falls back to the layout default until the seller overrides).
293 hero_title => 'hero_title',
294 hero_pill_label => 'hero_pill_label',
295 hero_cta_primary_label => 'hero_cta_primary_label',
296 hero_cta_secondary_label => 'hero_cta_secondary_label',
297 section_eyebrow => 'section_eyebrow',
298 header_signin_label => 'header_signin_label',
299 header_cart_label => 'header_cart_label',
300 # About-section + footer trust strip -- editable on every layout.
301 about_image => 'about_image',
302 about_image_pos => 'about_image_pos',
303 about_eyebrow => 'about_eyebrow',
304 about_heading => 'about_heading',
305 footer_trust => 'footer_trust',
306 );
307
308 my $field = $q->param('field') || '';
309 _upd_fail('missing field') unless $field;
310 _upd_fail("field '$field' is not editable") unless exists $ALLOWED{$field};
311 my $column = $ALLOWED{$field};
312
313 my $value = $q->param('value');
314 $value = '' unless defined $value;
315
316 # Per-field size caps and basic sanitation.
317 my %MAX_LEN = (
318 name => 120,
319 tagline => 255,
320 about_text => 4000,
321 hero_image_1 => 255,
322 hero_image_2 => 255,
323 hero_image_3 => 255,
324 hero_image_1_pos => 20,
325 hero_image_2_pos => 20,
326 hero_image_3_pos => 20,
327 cta_primary_label => 60,
328 cta_secondary_label => 60,
329 featured_badge_label => 60,
330 section_heading => 120,
331 footer_byline => 160,
332 featured_title => 200,
333 featured_price => 40,
334 featured_hero_image => 255,
335 featured_hero_pos => 20,
336 # Catalog-layout hero chrome -- added with the
337 # "edit everything on the page" pass. Must mirror %ALLOWED above;
338 # missing entries here cause substr() to clamp the value to '' and
339 # silently wipe whatever the seller typed.
340 hero_title => 200,
341 hero_pill_label => 60,
342 hero_cta_primary_label => 60,
343 hero_cta_secondary_label => 60,
344 section_eyebrow => 60,
345 header_signin_label => 40,
346 header_cart_label => 40,
347 about_image => 255,
348 about_image_pos => 20,
349 about_eyebrow => 60,
350 about_heading => 120,
351 footer_trust => 255,
352 );
353 $value = substr($value, 0, $MAX_LEN{$field}) if defined $MAX_LEN{$field} && length($value) > $MAX_LEN{$field};
354
355 # Find the user's storefront (single-storefront-per-user for now).
356 my $dbh = $db->db_connect();
357 my $store = $db->db_readwrite($dbh,
358 qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~,
359 $ENV{SCRIPT_NAME}, __LINE__);
360 unless ($store && $store->{id}) {
361 $db->db_disconnect($dbh);
362 _upd_fail('no storefront for this user');
363 }
364 my $sid = $store->{id};
365
366 # Escape ' and \ for the UPDATE. Other chars (HTML tags, line breaks,
367 # etc.) are stored as-is -- the field rendering layer escapes for HTML.
368 my $safe = $value;
369 $safe =~ s/\\/\\\\/g;
370 $safe =~ s/'/\\'/g;
371
372 # Wrap the UPDATE so a missing column (or any other SQL error) gets
373 # reported back to the editor as JSON instead of dying half-way and
374 # producing the dreaded "Unexpected end of JSON input" on the client.
375 my $err;
376 eval {
377 $db->db_readwrite($dbh,
378 qq~UPDATE `${DB}`.storefronts
379 SET `$column` = '$safe'
380 WHERE id = '$sid' LIMIT 1~,
381 $ENV{SCRIPT_NAME}, __LINE__);
382 1;
383 } or do { $err = $@ || 'unknown SQL error'; };
384
385 $db->db_disconnect($dbh);
386
387 if ($err) {
388 # Most likely cause: the storefronts table doesn't have the column
389 # yet -- the schema migration hasn't been run on this database.
390 my $msg = $err;
391 $msg =~ s/[\r\n]+/ /g;
392 $msg =~ s/"/\\"/g;
393 my $hint = '';
394 if ($msg =~ /Unknown column/i) {
395 $hint = " (the column may not exist yet -- run the storefronts ALTER from installation_instructions.html)";
396 }
397 print qq~{"success":0,"error":"SQL error: $msg$hint"}~;
398 $SAVE_DONE = 1;
399 return;
400 }
401
402 print qq~{"success":1,"field":"$field"}~;
403 $SAVE_DONE = 1;
404 return;
405}
406
407#======================================================================
408# update_listing entry: update a storefront_listings row.
409#
410# POST params:
411# action = feature | hide | show | toggle_visible | move_up | move_down | set_sort
412# model_id = which model's listing to update
413# value = (only used by set_sort) the new sort_order integer
414#
415# Authorization: the model must belong to the logged-in user AND the
416# user's storefront. Both are verified before any write.
417#======================================================================
418sub _upd_handle_listing {
419 _upd_fail('not authorized') unless MODS::WebSTLs::Permissions->new->has_feature($userinfo, 'edit_models');
420
421 my $action = lc($q->param('action') || '');
422 $action =~ s/[^a-z_]//g;
423 _upd_fail('missing action') unless $action;
424
425 my %ALLOWED_ACTIONS = map { $_ => 1 } qw(feature hide show toggle_visible move_up move_down set_sort);
426 _upd_fail("unknown action '$action'") unless $ALLOWED_ACTIONS{$action};
427
428 my $model_id = $q->param('model_id') || '';
429 $model_id =~ s/[^0-9]//g;
430 _upd_fail('missing or bad model_id') unless $model_id;
431
432 my $dbh = $db->db_connect();
433
434 # Resolve the user's storefront. Single-storefront-per-user for now.
435 my $store = $db->db_readwrite($dbh,
436 qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~,
437 $ENV{SCRIPT_NAME}, __LINE__);
438 unless ($store && $store->{id}) {
439 $db->db_disconnect($dbh);
440 _upd_fail('no storefront for this user');
441 }
442 my $sid = $store->{id};
443
444 # Verify the model belongs to this user and isn't purged.
445 my $owner = $db->db_readwrite($dbh,
446 qq~SELECT user_id, purged_at FROM `${DB}`.models WHERE id='$model_id' LIMIT 1~,
447 $ENV{SCRIPT_NAME}, __LINE__);
448 unless ($owner && $owner->{user_id} && $owner->{user_id} eq $uid) {
449 $db->db_disconnect($dbh);
450 _upd_fail('model not found or not yours');
451 }
452 if ($owner->{purged_at}) {
453 $db->db_disconnect($dbh);
454 _upd_fail('model has been permanently deleted');
455 }
456
457 # Make sure a listing row exists. Some actions (feature, show) should
458 # auto-create the listing if it isn't there yet; "hide" on a missing
459 # row is a no-op success.
460 my $listing = $db->db_readwrite($dbh,
461 qq~SELECT visible, sort_order FROM `${DB}`.storefront_listings
462 WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1~,
463 $ENV{SCRIPT_NAME}, __LINE__);
464
465 if (!$listing || !defined $listing->{visible}) {
466 if ($action eq 'hide') {
467 $db->db_disconnect($dbh);
468 print qq~{"success":1,"action":"$action","noop":1}~;
469 $SAVE_DONE = 1;
470 return;
471 }
472 # Create the row with a sensible default; feature/show will fix it up.
473 $db->db_readwrite($dbh, qq~
474 INSERT IGNORE INTO `${DB}`.storefront_listings SET
475 storefront_id = '$sid',
476 model_id = '$model_id',
477 visible = 1,
478 override_price_cents = NULL,
479 sort_order = 0,
480 added_at = NOW()
481 ~, $ENV{SCRIPT_NAME}, __LINE__);
482 }
483
484 #------------------------------------------------------------------
485 # Action dispatch
486 #------------------------------------------------------------------
487 if ($action eq 'feature') {
488 # Promote to the top: new sort_order = MIN(existing) - 1.
489 my $r = $db->db_readwrite($dbh,
490 qq~SELECT COALESCE(MIN(sort_order), 0) - 1 AS new_top
491 FROM `${DB}`.storefront_listings WHERE storefront_id='$sid'~,
492 $ENV{SCRIPT_NAME}, __LINE__);
493 my $new_top = (defined $r->{new_top}) ? $r->{new_top} : -1;
494 $db->db_readwrite($dbh, qq~
495 UPDATE `${DB}`.storefront_listings
496 SET sort_order = '$new_top', visible = 1
497 WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1
498 ~, $ENV{SCRIPT_NAME}, __LINE__);
499 }
500 elsif ($action eq 'hide') {
501 $db->db_readwrite($dbh, qq~
502 UPDATE `${DB}`.storefront_listings SET visible = 0
503 WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1
504 ~, $ENV{SCRIPT_NAME}, __LINE__);
505 }
506 elsif ($action eq 'show') {
507 $db->db_readwrite($dbh, qq~
508 UPDATE `${DB}`.storefront_listings SET visible = 1
509 WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1
510 ~, $ENV{SCRIPT_NAME}, __LINE__);
511 }
512 elsif ($action eq 'toggle_visible') {
513 my $newv = $listing && $listing->{visible} ? 0 : 1;
514 $db->db_readwrite($dbh, qq~
515 UPDATE `${DB}`.storefront_listings SET visible = '$newv'
516 WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1
517 ~, $ENV{SCRIPT_NAME}, __LINE__);
518 }
519 elsif ($action eq 'move_up' || $action eq 'move_down') {
520 # Find the neighbor in the chosen direction and swap sort_orders.
521 my $cur_sort = $listing->{sort_order} || 0;
522 my $cmp = ($action eq 'move_up') ? '<' : '>';
523 my $ord = ($action eq 'move_up') ? 'DESC' : 'ASC';
524 my $neighbor = $db->db_readwrite($dbh, qq~
525 SELECT model_id, sort_order FROM `${DB}`.storefront_listings
526 WHERE storefront_id='$sid' AND sort_order $cmp '$cur_sort'
527 ORDER BY sort_order $ord LIMIT 1
528 ~, $ENV{SCRIPT_NAME}, __LINE__);
529 if ($neighbor && defined $neighbor->{model_id}) {
530 my $n_mid = $neighbor->{model_id};
531 my $n_sort = $neighbor->{sort_order};
532 $db->db_readwrite($dbh, qq~
533 UPDATE `${DB}`.storefront_listings SET sort_order='$n_sort'
534 WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1
535 ~, $ENV{SCRIPT_NAME}, __LINE__);
536 $db->db_readwrite($dbh, qq~
537 UPDATE `${DB}`.storefront_listings SET sort_order='$cur_sort'
538 WHERE storefront_id='$sid' AND model_id='$n_mid' LIMIT 1
539 ~, $ENV{SCRIPT_NAME}, __LINE__);
540 }
541 }
542 elsif ($action eq 'set_sort') {
543 my $value = $q->param('value');
544 $value = 0 unless defined $value && length $value;
545 $value =~ s/[^0-9-]//g;
546 $value = int($value || 0);
547 $db->db_readwrite($dbh, qq~
548 UPDATE `${DB}`.storefront_listings SET sort_order='$value'
549 WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1
550 ~, $ENV{SCRIPT_NAME}, __LINE__);
551 }
552
553 $db->db_disconnect($dbh);
554
555 print qq~{"success":1,"action":"$action","model_id":"$model_id"}~;
556 $SAVE_DONE = 1;
557 return;
558}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help