added on WebSTLs (webstls.com) at 2026-07-01 22:27:10
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs -- 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/webstls.com/httpdocs'; | |
| 22 | use CGI; | |
| 23 | # Allow uploads up to 12 MB at the CGI layer. The on-disk size check | |
| 24 | # below still caps at 10 MB; this just keeps CGI from silently truncating | |
| 25 | # the body before we can inspect it. | |
| 26 | $CGI::POST_MAX = 12 * 1024 * 1024; | |
| 27 | use MODS::DBConnect; | |
| 28 | use MODS::Login; | |
| 29 | use MODS::WebSTLs::Config; | |
| 30 | ||
| 31 | $| = 1; # flush stdout so partial output reaches Apache before any exit | |
| 32 | ||
| 33 | my $q = CGI->new; | |
| 34 | my $db = MODS::DBConnect->new; | |
| 35 | my $auth = MODS::Login->new; | |
| 36 | my $config = MODS::WebSTLs::Config->new; | |
| 37 | my $DB = $config->settings('database_name'); | |
| 38 | ||
| 39 | # Always emit JSON, even on error -- the editor's fetch() expects it. | |
| 40 | print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 41 | ||
| 42 | # Safety net: if the script exits mid-flight (e.g., DBConnect's error() | |
| 43 | # helper hits exit on a failing SQL call), an END block fires and emits | |
| 44 | # a JSON error instead of the otherwise-empty body that produces the | |
| 45 | # editor's "server returned 200 (empty body)" message. | |
| 46 | our $UPLOAD_DONE = 0; | |
| 47 | END { | |
| 48 | if (!$UPLOAD_DONE) { | |
| 49 | print qq~{"success":0,"error":"server aborted before completing the upload -- check MODS/DB_ERRORS.log for the failing SQL"}~; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | sub fail { | |
| 54 | my ($msg) = @_; | |
| 55 | $msg =~ s/"/\\"/g; | |
| 56 | print qq~{"success":0,"error":"$msg"}~; | |
| 57 | $UPLOAD_DONE = 1; | |
| 58 | exit; | |
| 59 | } | |
| 60 | ||
| 61 | # If CGI::POST_MAX was exceeded, $q->cgi_error returns a status string | |
| 62 | # and $q->upload returns nothing. Surface that as JSON so the editor | |
| 63 | # shows a clean message instead of "Unexpected end of JSON input". | |
| 64 | if (my $cgi_err = $q->cgi_error) { | |
| 65 | fail("upload rejected: $cgi_err"); | |
| 66 | } | |
| 67 | ||
| 68 | my $userinfo = $auth->login_verify(); | |
| 69 | fail('not authenticated') unless $userinfo && $userinfo->{user_id}; | |
| 70 | require MODS::WebSTLs::Permissions; | |
| 71 | fail('not authorized') unless MODS::WebSTLs::Permissions->new->has_feature($userinfo, 'edit_models'); | |
| 72 | ||
| 73 | my $uid = $userinfo->{user_id}; | |
| 74 | $uid =~ s/[^0-9]//g; | |
| 75 | fail('bad user id') unless $uid; | |
| 76 | ||
| 77 | # Look up the user's storefront. Single-storefront-per-user for now. | |
| 78 | my $dbh = $db->db_connect(); | |
| 79 | my $store = $db->db_readwrite($dbh, | |
| 80 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~, | |
| 81 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 82 | unless ($store && $store->{id}) { | |
| 83 | $db->db_disconnect($dbh); | |
| 84 | fail('no storefront found for this user'); | |
| 85 | } | |
| 86 | my $sid = $store->{id}; | |
| 87 | ||
| 88 | # ---- Receive + validate the file ---- | |
| 89 | my $uploaded = $q->upload('image'); | |
| 90 | unless ($uploaded) { $db->db_disconnect($dbh); fail('no file received'); } | |
| 91 | ||
| 92 | my $tmpname = $q->tmpFileName($uploaded); | |
| 93 | unless ($tmpname && -f $tmpname) { $db->db_disconnect($dbh); fail('temp file missing'); } | |
| 94 | ||
| 95 | my $size = -s $tmpname; | |
| 96 | unless ($size && $size > 0) { $db->db_disconnect($dbh); fail('empty file'); } | |
| 97 | if ($size > 10 * 1024 * 1024) { $db->db_disconnect($dbh); fail('file too large (max 10MB)'); } | |
| 98 | ||
| 99 | open(my $fh, '<', $tmpname) or do { $db->db_disconnect($dbh); fail('cannot read temp'); }; | |
| 100 | binmode $fh; | |
| 101 | read($fh, my $head, 16); | |
| 102 | close $fh; | |
| 103 | ||
| 104 | my ($ext, $mime); | |
| 105 | if ($head =~ /^\xFF\xD8\xFF/) { $ext = 'jpg'; $mime = 'image/jpeg'; } | |
| 106 | elsif ($head =~ /^\x89PNG\r\n\x1A\n/) { $ext = 'png'; $mime = 'image/png'; } | |
| 107 | elsif ($head =~ /^GIF8[79]a/) { $ext = 'gif'; $mime = 'image/gif'; } | |
| 108 | elsif ($head =~ /^RIFF.{4}WEBP/s) { $ext = 'webp'; $mime = 'image/webp'; } | |
| 109 | else { $db->db_disconnect($dbh); fail('not a recognized image (JPG/PNG/GIF/WebP)'); } | |
| 110 | ||
| 111 | # ---- Generate a random 16-char id used in /img.cgi?id=<id> ---- | |
| 112 | my @hex = ('0'..'9', 'a'..'f'); | |
| 113 | my $id = ''; | |
| 114 | $id .= $hex[int(rand(@hex))] for 1..16; | |
| 115 | ||
| 116 | # ---- Save the file under uploads/<storefront_id>/<id>.<ext> ---- | |
| 117 | my $base_dir = '/var/www/vhosts/webstls.com/httpdocs/uploads'; | |
| 118 | my $store_dir = "$base_dir/$sid"; | |
| 119 | my $dest_path = "$store_dir/$id.$ext"; | |
| 120 | my $filename = "$id.$ext"; | |
| 121 | ||
| 122 | unless (-d $base_dir) { | |
| 123 | mkdir $base_dir, 0755 or do { $db->db_disconnect($dbh); fail("mkdir base: $!"); }; | |
| 124 | } | |
| 125 | unless (-d $store_dir) { | |
| 126 | mkdir $store_dir, 0755 or do { $db->db_disconnect($dbh); fail("mkdir store: $!"); }; | |
| 127 | } | |
| 128 | ||
| 129 | open(my $in_fh, '<', $tmpname) or do { $db->db_disconnect($dbh); fail("open temp: $!"); }; | |
| 130 | open(my $out_fh, '>', $dest_path) or do { $db->db_disconnect($dbh); fail("open dest: $!"); }; | |
| 131 | binmode $in_fh; | |
| 132 | binmode $out_fh; | |
| 133 | while (read($in_fh, my $buf, 8192)) { | |
| 134 | print $out_fh $buf; | |
| 135 | } | |
| 136 | close $in_fh; | |
| 137 | close $out_fh; | |
| 138 | ||
| 139 | # ---- DB insert mapping id -> file ---- | |
| 140 | $db->db_readwrite($dbh, qq~ | |
| 141 | INSERT INTO `${DB}`.page_images SET | |
| 142 | id = '$id', | |
| 143 | user_id = '$uid', | |
| 144 | storefront_id = '$sid', | |
| 145 | filename = '$filename', | |
| 146 | mime_type = '$mime', | |
| 147 | size_bytes = '$size' | |
| 148 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 149 | ||
| 150 | $db->db_disconnect($dbh); | |
| 151 | ||
| 152 | print qq~{"success":1,"url":"/img.cgi?id=$id","size":$size}~; | |
| 153 | $UPLOAD_DONE = 1; | |
| 154 | exit; |