added on local at 2026-07-01 22:10:07
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ShopCart -- update a single storefront field. | |
| 4 | # | |
| 5 | # POST params: | |
| 6 | # field = one of: name, tagline, about_text, hero_image_1/2/3 | |
| 7 | # value = new value (string) | |
| 8 | # | |
| 9 | # Used by the page editor's in-place WYSIWYG. On blur of an editable | |
| 10 | # element (or after an image is uploaded and selected) the iframe POSTs | |
| 11 | # here. We auth the user, verify they own the storefront, whitelist | |
| 12 | # the field, and UPDATE the row. | |
| 13 | # | |
| 14 | # Returns JSON { success: 1 } or { success: 0, error: "..." }. | |
| 15 | #====================================================================== | |
| 16 | use strict; | |
| 17 | use warnings; | |
| 18 | ||
| 19 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 20 | use CGI; | |
| 21 | use MODS::DBConnect; | |
| 22 | use MODS::Login; | |
| 23 | use MODS::ShopCart::Config; | |
| 24 | ||
| 25 | $| = 1; | |
| 26 | ||
| 27 | my $q = CGI->new; | |
| 28 | my $db = MODS::DBConnect->new; | |
| 29 | my $auth = MODS::Login->new; | |
| 30 | my $config = MODS::ShopCart::Config->new; | |
| 31 | my $DB = $config->settings('database_name'); | |
| 32 | ||
| 33 | print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 34 | ||
| 35 | # DBConnect's error() helper calls exit() on a failing SQL call, which | |
| 36 | # bypasses the eval{} around the UPDATE below and leaves the response | |
| 37 | # body empty -- the editor then sees "saveField: server returned 200 | |
| 38 | # (empty body)". This END block fires after any premature exit and | |
| 39 | # emits a useful JSON message so the client always gets a real error. | |
| 40 | our $SAVE_DONE = 0; | |
| 41 | END { | |
| 42 | if (!$SAVE_DONE) { | |
| 43 | print qq~{"success":0,"error":"server aborted mid-update -- check MODS/DB_ERRORS.log for the failing SQL and DBI message"}~; | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | sub fail { | |
| 48 | my ($msg) = @_; | |
| 49 | $msg =~ s/"/\\"/g; | |
| 50 | print qq~{"success":0,"error":"$msg"}~; | |
| 51 | $SAVE_DONE = 1; | |
| 52 | exit; | |
| 53 | } | |
| 54 | ||
| 55 | my $userinfo = $auth->login_verify(); | |
| 56 | fail('not authenticated') unless $userinfo && $userinfo->{user_id}; | |
| 57 | require MODS::ShopCart::Permissions; | |
| 58 | fail('not authorized') unless MODS::ShopCart::Permissions->new->has_feature($userinfo, 'edit_storefront'); | |
| 59 | ||
| 60 | my $uid = $userinfo->{user_id}; | |
| 61 | $uid =~ s/[^0-9]//g; | |
| 62 | fail('bad user id') unless $uid; | |
| 63 | ||
| 64 | # Whitelist of fields that can be updated through this endpoint. | |
| 65 | # Keys are the public field name (what the client sends); the value | |
| 66 | # is the SQL column to write (defense against column-injection even | |
| 67 | # though we sanitize separately). | |
| 68 | my %ALLOWED = ( | |
| 69 | name => 'name', | |
| 70 | tagline => 'tagline', | |
| 71 | about_text => 'about_text', | |
| 72 | hero_image_1 => 'hero_image_1', | |
| 73 | hero_image_2 => 'hero_image_2', | |
| 74 | hero_image_3 => 'hero_image_3', | |
| 75 | hero_image_1_pos => 'hero_image_1_pos', | |
| 76 | hero_image_2_pos => 'hero_image_2_pos', | |
| 77 | hero_image_3_pos => 'hero_image_3_pos', | |
| 78 | cta_primary_label => 'cta_primary_label', | |
| 79 | cta_secondary_label => 'cta_secondary_label', | |
| 80 | featured_badge_label => 'featured_badge_label', | |
| 81 | section_heading => 'section_heading', | |
| 82 | footer_byline => 'footer_byline', | |
| 83 | featured_title => 'featured_title', | |
| 84 | featured_price => 'featured_price', | |
| 85 | featured_hero_image => 'featured_hero_image', | |
| 86 | featured_hero_pos => 'featured_hero_pos', | |
| 87 | # Catalog-layout hero chrome -- added with the | |
| 88 | # "edit everything on the page" pass. Schema-guarded in store.cgi | |
| 89 | # (NULL falls back to the layout default until the seller overrides). | |
| 90 | hero_title => 'hero_title', | |
| 91 | hero_pill_label => 'hero_pill_label', | |
| 92 | hero_cta_primary_label => 'hero_cta_primary_label', | |
| 93 | hero_cta_secondary_label => 'hero_cta_secondary_label', | |
| 94 | section_eyebrow => 'section_eyebrow', | |
| 95 | header_signin_label => 'header_signin_label', | |
| 96 | header_cart_label => 'header_cart_label', | |
| 97 | # About-section + footer trust strip -- editable on every layout. | |
| 98 | about_image => 'about_image', | |
| 99 | about_image_pos => 'about_image_pos', | |
| 100 | about_eyebrow => 'about_eyebrow', | |
| 101 | about_heading => 'about_heading', | |
| 102 | footer_trust => 'footer_trust', | |
| 103 | ); | |
| 104 | ||
| 105 | my $field = $q->param('field') || ''; | |
| 106 | fail('missing field') unless $field; | |
| 107 | fail("field '$field' is not editable") unless exists $ALLOWED{$field}; | |
| 108 | my $column = $ALLOWED{$field}; | |
| 109 | ||
| 110 | my $value = $q->param('value'); | |
| 111 | $value = '' unless defined $value; | |
| 112 | ||
| 113 | # Per-field size caps and basic sanitation. | |
| 114 | my %MAX_LEN = ( | |
| 115 | name => 120, | |
| 116 | tagline => 255, | |
| 117 | about_text => 4000, | |
| 118 | hero_image_1 => 255, | |
| 119 | hero_image_2 => 255, | |
| 120 | hero_image_3 => 255, | |
| 121 | hero_image_1_pos => 20, | |
| 122 | hero_image_2_pos => 20, | |
| 123 | hero_image_3_pos => 20, | |
| 124 | cta_primary_label => 60, | |
| 125 | cta_secondary_label => 60, | |
| 126 | featured_badge_label => 60, | |
| 127 | section_heading => 120, | |
| 128 | footer_byline => 160, | |
| 129 | featured_title => 200, | |
| 130 | featured_price => 40, | |
| 131 | featured_hero_image => 255, | |
| 132 | featured_hero_pos => 20, | |
| 133 | # Catalog-layout hero chrome -- added with the | |
| 134 | # "edit everything on the page" pass. Must mirror %ALLOWED above; | |
| 135 | # missing entries here cause substr() to clamp the value to '' and | |
| 136 | # silently wipe whatever the seller typed. | |
| 137 | hero_title => 200, | |
| 138 | hero_pill_label => 60, | |
| 139 | hero_cta_primary_label => 60, | |
| 140 | hero_cta_secondary_label => 60, | |
| 141 | section_eyebrow => 60, | |
| 142 | header_signin_label => 40, | |
| 143 | header_cart_label => 40, | |
| 144 | about_image => 255, | |
| 145 | about_image_pos => 20, | |
| 146 | about_eyebrow => 60, | |
| 147 | about_heading => 120, | |
| 148 | footer_trust => 255, | |
| 149 | ); | |
| 150 | $value = substr($value, 0, $MAX_LEN{$field}) if defined $MAX_LEN{$field} && length($value) > $MAX_LEN{$field}; | |
| 151 | ||
| 152 | # Find the user's storefront (single-storefront-per-user for now). | |
| 153 | my $dbh = $db->db_connect(); | |
| 154 | my $store = $db->db_readwrite($dbh, | |
| 155 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~, | |
| 156 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 157 | unless ($store && $store->{id}) { | |
| 158 | $db->db_disconnect($dbh); | |
| 159 | fail('no storefront for this user'); | |
| 160 | } | |
| 161 | my $sid = $store->{id}; | |
| 162 | ||
| 163 | # Escape ' and \ for the UPDATE. Other chars (HTML tags, line breaks, | |
| 164 | # etc.) are stored as-is -- the field rendering layer escapes for HTML. | |
| 165 | my $safe = $value; | |
| 166 | $safe =~ s/\\/\\\\/g; | |
| 167 | $safe =~ s/'/\\'/g; | |
| 168 | ||
| 169 | # Wrap the UPDATE so a missing column (or any other SQL error) gets | |
| 170 | # reported back to the editor as JSON instead of dying half-way and | |
| 171 | # producing the dreaded "Unexpected end of JSON input" on the client. | |
| 172 | my $err; | |
| 173 | eval { | |
| 174 | $db->db_readwrite($dbh, | |
| 175 | qq~UPDATE `${DB}`.storefronts | |
| 176 | SET `$column` = '$safe' | |
| 177 | WHERE id = '$sid' LIMIT 1~, | |
| 178 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 179 | 1; | |
| 180 | } or do { $err = $@ || 'unknown SQL error'; }; | |
| 181 | ||
| 182 | $db->db_disconnect($dbh); | |
| 183 | ||
| 184 | if ($err) { | |
| 185 | # Most likely cause: the storefronts table doesn't have the column | |
| 186 | # yet -- the schema migration hasn't been run on this database. | |
| 187 | my $msg = $err; | |
| 188 | $msg =~ s/[\r\n]+/ /g; | |
| 189 | $msg =~ s/"/\\"/g; | |
| 190 | my $hint = ''; | |
| 191 | if ($msg =~ /Unknown column/i) { | |
| 192 | $hint = " (the column may not exist yet -- run the storefronts ALTER from installation_instructions.html)"; | |
| 193 | } | |
| 194 | print qq~{"success":0,"error":"SQL error: $msg$hint"}~; | |
| 195 | $SAVE_DONE = 1; | |
| 196 | exit; | |
| 197 | } | |
| 198 | ||
| 199 | print qq~{"success":1,"field":"$field"}~; | |
| 200 | $SAVE_DONE = 1; | |
| 201 | exit; |