added on local at 2026-07-01 22:10:08
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ShopCart -- image upload endpoint for the page editor. | |
| 4 | # | |
| 5 | # Accepts multipart/form-data POST with field "image". On success: | |
| 6 | # 1. Looks up the user's storefront. | |
| 7 | # 2. Validates the file (JPG/PNG/GIF/WebP, max 5MB) by magic bytes. | |
| 8 | # 3. Generates a 16-char random id used in the public URL. | |
| 9 | # 4. Saves bytes to httpdocs/uploads/<storefront_id>/<id>.<ext>. | |
| 10 | # 5. INSERTs a page_images row mapping id -> on-disk file. | |
| 11 | # 6. Returns JSON { success: 1, url: "/img.cgi?id=<id>" }. | |
| 12 | # | |
| 13 | # The on-disk path is NEVER exposed in the response. The client only | |
| 14 | # sees /img.cgi?id=<token>; img.cgi resolves the path and streams the | |
| 15 | # bytes. A .htaccess in /uploads/ blocks direct HTTP access as a | |
| 16 | # defense-in-depth measure. | |
| 17 | #====================================================================== | |
| 18 | use strict; | |
| 19 | use warnings; | |
| 20 | ||
| 21 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 22 | use CGI; | |
| 23 | # Allow uploads up to the Business-tier ceiling at the CGI layer | |
| 24 | # (5 GB = 5120 MB). The Storage.pm gate below applies the per-user | |
| 25 | # plan-aware cap before any bytes are saved to disk. | |
| 26 | $CGI::POST_MAX = 5368709120; | |
| 27 | use MODS::DBConnect; | |
| 28 | use MODS::Login; | |
| 29 | use MODS::ShopCart::Config; | |
| 30 | use MODS::ShopCart::Storage; | |
| 31 | ||
| 32 | $| = 1; # flush stdout so partial output reaches Apache before any exit | |
| 33 | ||
| 34 | my $q = CGI->new; | |
| 35 | my $db = MODS::DBConnect->new; | |
| 36 | my $auth = MODS::Login->new; | |
| 37 | my $config = MODS::ShopCart::Config->new; | |
| 38 | my $DB = $config->settings('database_name'); | |
| 39 | ||
| 40 | # Always emit JSON, even on error -- the editor's fetch() expects it. | |
| 41 | print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 42 | ||
| 43 | # Safety net: if the script exits mid-flight (e.g., DBConnect's error() | |
| 44 | # helper hits exit on a failing SQL call), an END block fires and emits | |
| 45 | # a JSON error instead of the otherwise-empty body that produces the | |
| 46 | # editor's "server returned 200 (empty body)" message. | |
| 47 | our $UPLOAD_DONE = 0; | |
| 48 | END { | |
| 49 | if (!$UPLOAD_DONE) { | |
| 50 | print qq~{"success":0,"error":"server aborted before completing the upload -- check MODS/DB_ERRORS.log for the failing SQL"}~; | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | sub fail { | |
| 55 | my ($msg) = @_; | |
| 56 | $msg =~ s/"/\\"/g; | |
| 57 | print qq~{"success":0,"error":"$msg"}~; | |
| 58 | $UPLOAD_DONE = 1; | |
| 59 | exit; | |
| 60 | } | |
| 61 | ||
| 62 | # If CGI::POST_MAX was exceeded, $q->cgi_error returns a status string | |
| 63 | # and $q->upload returns nothing. Surface that as JSON so the editor | |
| 64 | # shows a clean message instead of "Unexpected end of JSON input". | |
| 65 | if (my $cgi_err = $q->cgi_error) { | |
| 66 | fail("upload rejected: $cgi_err"); | |
| 67 | } | |
| 68 | ||
| 69 | my $userinfo = $auth->login_verify(); | |
| 70 | fail('not authenticated') unless $userinfo && $userinfo->{user_id}; | |
| 71 | require MODS::ShopCart::Permissions; | |
| 72 | fail('not authorized') unless MODS::ShopCart::Permissions->new->has_feature($userinfo, 'edit_models'); | |
| 73 | ||
| 74 | my $uid = $userinfo->{user_id}; | |
| 75 | $uid =~ s/[^0-9]//g; | |
| 76 | fail('bad user id') unless $uid; | |
| 77 | ||
| 78 | # Look up the user's storefront. Single-storefront-per-user for now. | |
| 79 | my $dbh = $db->db_connect(); | |
| 80 | my $store = $db->db_readwrite($dbh, | |
| 81 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~, | |
| 82 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 83 | unless ($store && $store->{id}) { | |
| 84 | $db->db_disconnect($dbh); | |
| 85 | fail('no storefront found for this user'); | |
| 86 | } | |
| 87 | my $sid = $store->{id}; | |
| 88 | ||
| 89 | # ---- Receive + validate the file ---- | |
| 90 | my $uploaded = $q->upload('image'); | |
| 91 | unless ($uploaded) { $db->db_disconnect($dbh); fail('no file received'); } | |
| 92 | ||
| 93 | my $tmpname = $q->tmpFileName($uploaded); | |
| 94 | unless ($tmpname && -f $tmpname) { $db->db_disconnect($dbh); fail('temp file missing'); } | |
| 95 | ||
| 96 | my $size = -s $tmpname; | |
| 97 | unless ($size && $size > 0) { $db->db_disconnect($dbh); fail('empty file'); } | |
| 98 | ||
| 99 | # Plan-aware per-file + total-storage gate. Runs after the temp file | |
| 100 | # lands but BEFORE we persist it to /uploads + DB, so a rejected upload | |
| 101 | # leaves no on-disk residue. | |
| 102 | { | |
| 103 | my $sto = MODS::ShopCart::Storage->new; | |
| 104 | my ($ok, $err) = $sto->check_upload_allowed($db, $dbh, $DB, $uid, $size); | |
| 105 | unless ($ok) { | |
| 106 | $db->db_disconnect($dbh); | |
| 107 | # 413 Payload Too Large -- the editor + form code already handles | |
| 108 | # this shape from the messaging feature build. | |
| 109 | print "Status: 413 Payload Too Large\n"; # ignored if headers already sent | |
| 110 | fail($err || 'upload rejected by storage policy'); | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | open(my $fh, '<', $tmpname) or do { $db->db_disconnect($dbh); fail('cannot read temp'); }; | |
| 115 | binmode $fh; | |
| 116 | read($fh, my $head, 16); | |
| 117 | close $fh; | |
| 118 | ||
| 119 | my ($ext, $mime); | |
| 120 | if ($head =~ /^\xFF\xD8\xFF/) { $ext = 'jpg'; $mime = 'image/jpeg'; } | |
| 121 | elsif ($head =~ /^\x89PNG\r\n\x1A\n/) { $ext = 'png'; $mime = 'image/png'; } | |
| 122 | elsif ($head =~ /^GIF8[79]a/) { $ext = 'gif'; $mime = 'image/gif'; } | |
| 123 | elsif ($head =~ /^RIFF.{4}WEBP/s) { $ext = 'webp'; $mime = 'image/webp'; } | |
| 124 | else { $db->db_disconnect($dbh); fail('not a recognized image (JPG/PNG/GIF/WebP)'); } | |
| 125 | ||
| 126 | # ---- Generate a random 16-char id used in /img.cgi?id=<id> ---- | |
| 127 | my @hex = ('0'..'9', 'a'..'f'); | |
| 128 | my $id = ''; | |
| 129 | $id .= $hex[int(rand(@hex))] for 1..16; | |
| 130 | ||
| 131 | # ---- Save the file under uploads/<storefront_id>/<id>.<ext> ---- | |
| 132 | my $base_dir = '/var/www/vhosts/3dshawn.com/shop.3dshawn.com/uploads'; | |
| 133 | my $store_dir = "$base_dir/$sid"; | |
| 134 | my $dest_path = "$store_dir/$id.$ext"; | |
| 135 | my $filename = "$id.$ext"; | |
| 136 | ||
| 137 | unless (-d $base_dir) { | |
| 138 | mkdir $base_dir, 0755 or do { $db->db_disconnect($dbh); fail("mkdir base: $!"); }; | |
| 139 | } | |
| 140 | unless (-d $store_dir) { | |
| 141 | mkdir $store_dir, 0755 or do { $db->db_disconnect($dbh); fail("mkdir store: $!"); }; | |
| 142 | } | |
| 143 | ||
| 144 | open(my $in_fh, '<', $tmpname) or do { $db->db_disconnect($dbh); fail("open temp: $!"); }; | |
| 145 | open(my $out_fh, '>', $dest_path) or do { $db->db_disconnect($dbh); fail("open dest: $!"); }; | |
| 146 | binmode $in_fh; | |
| 147 | binmode $out_fh; | |
| 148 | while (read($in_fh, my $buf, 8192)) { | |
| 149 | print $out_fh $buf; | |
| 150 | } | |
| 151 | close $in_fh; | |
| 152 | close $out_fh; | |
| 153 | ||
| 154 | # ---- DB insert mapping id -> file ---- | |
| 155 | $db->db_readwrite($dbh, qq~ | |
| 156 | INSERT INTO `${DB}`.page_images SET | |
| 157 | id = '$id', | |
| 158 | user_id = '$uid', | |
| 159 | storefront_id = '$sid', | |
| 160 | filename = '$filename', | |
| 161 | mime_type = '$mime', | |
| 162 | size_bytes = '$size' | |
| 163 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 164 | ||
| 165 | # Bump denormalized storage total now that the file is committed. | |
| 166 | MODS::ShopCart::Storage->new->bump_usage($db, $dbh, $DB, $uid, $size); | |
| 167 | ||
| 168 | $db->db_disconnect($dbh); | |
| 169 | ||
| 170 | print qq~{"success":1,"url":"/img.cgi?id=$id","size":$size}~; | |
| 171 | $UPLOAD_DONE = 1; | |
| 172 | exit; |