Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/upload_image.cgi
Diff

/var/www/vhosts/3dshawn.com/shop.3dshawn.com/upload_image.cgi

added on local at 2026-07-01 22:10:08

Added
+172
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 142019fedf29
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# 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#======================================================================
18use strict;
19use warnings;
20
21use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
22use 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;
27use MODS::DBConnect;
28use MODS::Login;
29use MODS::ShopCart::Config;
30use MODS::ShopCart::Storage;
31
32$| = 1; # flush stdout so partial output reaches Apache before any exit
33
34my $q = CGI->new;
35my $db = MODS::DBConnect->new;
36my $auth = MODS::Login->new;
37my $config = MODS::ShopCart::Config->new;
38my $DB = $config->settings('database_name');
39
40# Always emit JSON, even on error -- the editor's fetch() expects it.
41print "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.
47our $UPLOAD_DONE = 0;
48END {
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
54sub 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".
65if (my $cgi_err = $q->cgi_error) {
66 fail("upload rejected: $cgi_err");
67}
68
69my $userinfo = $auth->login_verify();
70fail('not authenticated') unless $userinfo && $userinfo->{user_id};
71require MODS::ShopCart::Permissions;
72fail('not authorized') unless MODS::ShopCart::Permissions->new->has_feature($userinfo, 'edit_models');
73
74my $uid = $userinfo->{user_id};
75$uid =~ s/[^0-9]//g;
76fail('bad user id') unless $uid;
77
78# Look up the user's storefront. Single-storefront-per-user for now.
79my $dbh = $db->db_connect();
80my $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__);
83unless ($store && $store->{id}) {
84 $db->db_disconnect($dbh);
85 fail('no storefront found for this user');
86}
87my $sid = $store->{id};
88
89# ---- Receive + validate the file ----
90my $uploaded = $q->upload('image');
91unless ($uploaded) { $db->db_disconnect($dbh); fail('no file received'); }
92
93my $tmpname = $q->tmpFileName($uploaded);
94unless ($tmpname && -f $tmpname) { $db->db_disconnect($dbh); fail('temp file missing'); }
95
96my $size = -s $tmpname;
97unless ($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
114open(my $fh, '<', $tmpname) or do { $db->db_disconnect($dbh); fail('cannot read temp'); };
115binmode $fh;
116read($fh, my $head, 16);
117close $fh;
118
119my ($ext, $mime);
120if ($head =~ /^\xFF\xD8\xFF/) { $ext = 'jpg'; $mime = 'image/jpeg'; }
121elsif ($head =~ /^\x89PNG\r\n\x1A\n/) { $ext = 'png'; $mime = 'image/png'; }
122elsif ($head =~ /^GIF8[79]a/) { $ext = 'gif'; $mime = 'image/gif'; }
123elsif ($head =~ /^RIFF.{4}WEBP/s) { $ext = 'webp'; $mime = 'image/webp'; }
124else { $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> ----
127my @hex = ('0'..'9', 'a'..'f');
128my $id = '';
129$id .= $hex[int(rand(@hex))] for 1..16;
130
131# ---- Save the file under uploads/<storefront_id>/<id>.<ext> ----
132my $base_dir = '/var/www/vhosts/3dshawn.com/shop.3dshawn.com/uploads';
133my $store_dir = "$base_dir/$sid";
134my $dest_path = "$store_dir/$id.$ext";
135my $filename = "$id.$ext";
136
137unless (-d $base_dir) {
138 mkdir $base_dir, 0755 or do { $db->db_disconnect($dbh); fail("mkdir base: $!"); };
139}
140unless (-d $store_dir) {
141 mkdir $store_dir, 0755 or do { $db->db_disconnect($dbh); fail("mkdir store: $!"); };
142}
143
144open(my $in_fh, '<', $tmpname) or do { $db->db_disconnect($dbh); fail("open temp: $!"); };
145open(my $out_fh, '>', $dest_path) or do { $db->db_disconnect($dbh); fail("open dest: $!"); };
146binmode $in_fh;
147binmode $out_fh;
148while (read($in_fh, my $buf, 8192)) {
149 print $out_fh $buf;
150}
151close $in_fh;
152close $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.
166MODS::ShopCart::Storage->new->bump_usage($db, $dbh, $DB, $uid, $size);
167
168$db->db_disconnect($dbh);
169
170print qq~{"success":1,"url":"/img.cgi?id=$id","size":$size}~;
171$UPLOAD_DONE = 1;
172exit;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help