added on local at 2026-07-01 22:09:34
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ShopCart -- image serving CGI. | |
| 4 | # | |
| 5 | # /img.cgi?id=<16-char token> -> page_images entry (page editor) | |
| 6 | # /img.cgi?m=<model_image_id> -> model_images entry (upload form) | |
| 7 | # | |
| 8 | # Looks up the requested record, resolves the on-disk file path, and | |
| 9 | # streams the bytes with the right Content-Type. The on-disk location | |
| 10 | # is never revealed in any response. Direct HTTP access to /uploads/ | |
| 11 | # is also blocked by .htaccess as defense-in-depth. | |
| 12 | # | |
| 13 | # No authentication is required for either mode: storefront pages are | |
| 14 | # public, so the images embedded in them are too. Token mode has 64 | |
| 15 | # bits of entropy; m-mode uses auto-increment ids and is enumerable | |
| 16 | # but that matches the public-by-design model of the storefront. | |
| 17 | #====================================================================== | |
| 18 | use strict; | |
| 19 | use warnings; | |
| 20 | ||
| 21 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 22 | use CGI; | |
| 23 | use MODS::DBConnect; | |
| 24 | use MODS::ShopCart::Config; | |
| 25 | ||
| 26 | my $q = CGI->new; | |
| 27 | my $db = MODS::DBConnect->new; | |
| 28 | my $config = MODS::ShopCart::Config->new; | |
| 29 | my $DB = $config->settings('database_name'); | |
| 30 | ||
| 31 | sub not_found { | |
| 32 | print "Status: 404 Not Found\nContent-Type: text/plain\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 33 | print "image not found\n"; | |
| 34 | exit; | |
| 35 | } | |
| 36 | ||
| 37 | my $path; | |
| 38 | my $mime; | |
| 39 | ||
| 40 | if (my $mid_param = $q->param('m')) { | |
| 41 | # ---- Model image mode --------------------------------------- | |
| 42 | my $mid = $mid_param; | |
| 43 | $mid =~ s/[^0-9]//g; | |
| 44 | not_found() unless $mid; | |
| 45 | ||
| 46 | my $dbh = $db->db_connect(); | |
| 47 | my $row = $db->db_readwrite($dbh, | |
| 48 | qq~SELECT storage_key FROM `${DB}`.model_images WHERE id='$mid' LIMIT 1~, | |
| 49 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 50 | $db->db_disconnect($dbh); | |
| 51 | ||
| 52 | not_found() unless $row && $row->{storage_key}; | |
| 53 | my $key = $row->{storage_key}; | |
| 54 | # Reject any path traversal -- storage_key must be a relative path | |
| 55 | # inside uploads/, no .. components, no leading slash. | |
| 56 | not_found() if $key =~ /\.\./; | |
| 57 | not_found() if $key =~ m{^/}; | |
| 58 | not_found() unless $key =~ m{^uploads/}; | |
| 59 | $path = "/var/www/vhosts/3dshawn.com/shop.3dshawn.com/$key"; | |
| 60 | ||
| 61 | # Derive mime from extension. | |
| 62 | my ($ext) = lc($key) =~ /\.([a-z0-9]+)$/; | |
| 63 | my %ext_mime = ( | |
| 64 | jpg => 'image/jpeg', jpeg => 'image/jpeg', | |
| 65 | png => 'image/png', gif => 'image/gif', | |
| 66 | webp => 'image/webp', | |
| 67 | ); | |
| 68 | $mime = $ext_mime{$ext || ''} || 'application/octet-stream'; | |
| 69 | } else { | |
| 70 | # ---- Page image (token) mode -------------------------------- | |
| 71 | my $id = $q->param('id') || ''; | |
| 72 | $id =~ s/[^a-zA-Z0-9]//g; | |
| 73 | not_found() unless length($id) >= 8 && length($id) <= 32; | |
| 74 | ||
| 75 | my $dbh = $db->db_connect(); | |
| 76 | my $row = $db->db_readwrite($dbh, | |
| 77 | qq~SELECT storefront_id, filename, mime_type, size_bytes | |
| 78 | FROM `${DB}`.page_images | |
| 79 | WHERE id='$id' | |
| 80 | LIMIT 1~, | |
| 81 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 82 | $db->db_disconnect($dbh); | |
| 83 | ||
| 84 | not_found() unless $row && $row->{filename}; | |
| 85 | ||
| 86 | my $filename = $row->{filename}; | |
| 87 | not_found() if $filename =~ m{[/\\]} || $filename =~ /\.\./; | |
| 88 | ||
| 89 | my $sid = $row->{storefront_id}; | |
| 90 | $sid =~ s/[^0-9]//g; | |
| 91 | not_found() unless $sid; | |
| 92 | ||
| 93 | $path = "/var/www/vhosts/3dshawn.com/shop.3dshawn.com/uploads/$sid/$filename"; | |
| 94 | $mime = $row->{mime_type} || 'application/octet-stream'; | |
| 95 | } | |
| 96 | ||
| 97 | not_found() unless -r $path; | |
| 98 | ||
| 99 | my $size = -s $path; | |
| 100 | # Whitelist mime types -- never echo back an arbitrary string. | |
| 101 | my %ok_mime = map { $_ => 1 } qw(image/jpeg image/png image/gif image/webp); | |
| 102 | $mime = 'application/octet-stream' unless $ok_mime{$mime}; | |
| 103 | ||
| 104 | # Caching: the id is part of the URL and is unique per upload, so the | |
| 105 | # bytes for any given id are immutable. Long cache + immutable hint. | |
| 106 | print "Content-Type: $mime\n"; | |
| 107 | print "Content-Length: $size\n"; | |
| 108 | print "Cache-Control: public, max-age=31536000, immutable\n"; | |
| 109 | print "X-Content-Type-Options: nosniff\n"; | |
| 110 | print "\n"; | |
| 111 | ||
| 112 | binmode STDOUT; | |
| 113 | open(my $fh, '<', $path) or not_found(); | |
| 114 | binmode $fh; | |
| 115 | my $buf; | |
| 116 | while (read($fh, $buf, 65536)) { | |
| 117 | print STDOUT $buf; | |
| 118 | } | |
| 119 | close $fh; | |
| 120 | exit; |