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

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

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

Added
+1163
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 47bc0de72a50
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 -- Upload & publish model.
4#
5# GET /upload_model.cgi -- renders the upload form (shopcart_upload_model.html).
6# POST /upload_model.cgi -- accepts multipart/form-data:
7# - model_files[] 3D files (STL/OBJ/STEP/3MF/ZIP)
8# - model_images[] gallery images
9# - title, tagline, description, category, tags
10# - price_mode (free|paid), price
11# - visibility (public|unlisted|private)
12# - license_type
13# - schedule_mode (now|scheduled|patreon_first)
14# - scheduled_at (datetime-local string)
15# - opt_*, compat_*, mat_*, spec_*, ps_*
16# - highlights_json, included_json, videos_json
17# - publish_to[] marketplace slugs (limit 2 on free)
18# ...and inserts:
19# - one models row
20# - model_files rows (with files saved to disk)
21# - model_images rows (with files saved to disk)
22# - model_tags rows (split from comma list)
23# - one marketplace_pushes row per selected slug
24# ...then redirects to /models.cgi?published=N
25#======================================================================
26use strict;
27use warnings;
28
29use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
30use CGI;
31# Cap raw POST at the Business-tier per-file ceiling (5 GB). Storage.pm
32# applies the per-user plan-aware gate inside save_uploaded_files. We
33# set this BEFORE creating CGI->new so the body parse honors it.
34$CGI::POST_MAX = 5368709120;
35use MODS::Template;
36use MODS::DBConnect;
37use MODS::Login;
38use MODS::ShopCart::Config;
39use MODS::ShopCart::Wrapper;
40use MODS::ShopCart::Categories;
41use MODS::ShopCart::Storage;
42
43my $q = CGI->new;
44my $form = $q->Vars;
45my $tfile = MODS::Template->new;
46my $db = MODS::DBConnect->new;
47my $auth = MODS::Login->new;
48my $config = MODS::ShopCart::Config->new;
49my $wrap = MODS::ShopCart::Wrapper->new;
50my $mp = MODS::ShopCart::Marketplaces->new;
51my $catmod = MODS::ShopCart::Categories->new;
52my $DB = $config->settings('database_name');
53
54$| = 1;
55
56my $userinfo = $auth->login_verify();
57if (!$userinfo) {
58 print "Status: 302 Found\nLocation: /login.cgi\n\n";
59 exit;
60}
61require MODS::ShopCart::Permissions;
62MODS::ShopCart::Permissions->new->require_feature($userinfo, 'edit_models');
63
64my $uid = $userinfo->{user_id};
65$uid =~ s/[^0-9]//g;
66
67# Free plan = demo mode (max 2 marketplace pushes).
68my $plan_tier = lc($userinfo->{plan_tier} || 'free');
69my $is_demo = ($plan_tier eq 'free') ? 1 : 0;
70my $demo_max = 2;
71
72#======================================================================
73# POST -- save the model OR handle an AJAX remove action
74#======================================================================
75if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
76 # AJAX removal of an existing image or file from a model.
77 my $ajax = lc($form->{_ajax} || '');
78 $ajax =~ s/[^a-z_]//g;
79 if ($ajax eq 'remove_image' || $ajax eq 'remove_file') {
80 handle_ajax_remove($ajax);
81 exit;
82 }
83 handle_post();
84 exit;
85}
86
87sub handle_ajax_remove {
88 my ($kind) = @_;
89 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
90 my $row_id = $form->{row_id} || 0;
91 $row_id =~ s/[^0-9]//g;
92 unless ($row_id) {
93 print qq~{"success":0,"error":"missing row_id"}~;
94 return;
95 }
96 my $dbh = $db->db_connect();
97 my $table = ($kind eq 'remove_image') ? 'model_images' : 'model_files';
98 # Verify the row belongs to a model the user owns.
99 my $owner = $db->db_readwrite($dbh, qq~
100 SELECT m.user_id, t.storage_key
101 FROM `${DB}`.$table t
102 JOIN `${DB}`.models m ON m.id = t.model_id
103 WHERE t.id = '$row_id' LIMIT 1
104 ~, $ENV{SCRIPT_NAME}, __LINE__);
105 unless ($owner && $owner->{user_id} && $owner->{user_id} eq $uid) {
106 $db->db_disconnect($dbh);
107 print qq~{"success":0,"error":"row not yours"}~;
108 return;
109 }
110 # Delete the row.
111 $db->db_readwrite($dbh,
112 qq~DELETE FROM `${DB}`.$table WHERE id='$row_id' LIMIT 1~,
113 $ENV{SCRIPT_NAME}, __LINE__);
114 $db->db_disconnect($dbh);
115
116 # Best-effort on-disk cleanup. storage_key is a relative path
117 # under uploads/. Guard against traversal -- only remove files
118 # that live below httpdocs/uploads/.
119 my $key = $owner->{storage_key} || '';
120 if ($key && $key !~ /\.\./ && $key =~ m{^uploads/}) {
121 my $path = "/var/www/vhosts/3dshawn.com/shop.3dshawn.com/$key";
122 unlink $path if -f $path;
123 }
124
125 print qq~{"success":1,"row_id":"$row_id"}~;
126}
127
128#======================================================================
129# GET -- render the form
130#
131# Two modes:
132# /upload_model.cgi -> blank form, creates a new model
133# /upload_model.cgi?model_id=N -> pre-filled form, edits the existing
134# model. Verifies ownership before
135# exposing any data.
136#======================================================================
137my $edit_id = ($form->{model_id} || $form->{id} || 0);
138$edit_id =~ s/[^0-9]//g;
139
140# Seller's categories. Two surfaces:
141# 1. The primary Category <select> -- single value stored in models.category
142# as the seller_category slug.
143# 2. The "Your collections" multi-tag chip picker (existing) -- multi-value
144# stored in model_seller_categories.
145# On first visit we lazy-seed from default_categories so the seller has
146# the platform default list to pick from without doing anything.
147my @seller_cats; # for chip picker
148my @category_picklist; # for primary <select>
149{
150 my $dbh = $db->db_connect();
151 $catmod->seed_for_user_if_unseeded($db, $dbh, $DB, $uid);
152 my $cats = $catmod->list_for_user($db, $dbh, $DB, $uid);
153 my %selected;
154 if ($edit_id) {
155 my $on = $catmod->for_model($db, $dbh, $DB, $edit_id);
156 %selected = map { $_->{id} => 1 } @$on;
157 }
158 foreach my $c (@$cats) {
159 push @seller_cats, {
160 id => $c->{id},
161 name => _ha($c->{name}),
162 color => _ha($c->{color} || '#a78bfa'),
163 icon => _ha($c->{icon} || ''),
164 checked => $selected{$c->{id}} ? 'checked' : '',
165 };
166 push @category_picklist, [ $c->{slug}, _ha($c->{name}) ];
167 }
168 $db->db_disconnect($dbh);
169}
170
171my @marketplaces;
172foreach my $p (@{ $mp->all }) {
173 push @marketplaces, {
174 slug => $p->{slug},
175 name => $p->{name},
176 icon => $p->{icon},
177 fees => $p->{fees},
178 sells_digital => $p->{sells_digital} ? 1 : 0,
179 sells_physical => $p->{sells_physical} ? 1 : 0,
180 };
181}
182
183my $tvars = {
184 user_id => $uid,
185 is_demo => $is_demo,
186 demo_max => $demo_max,
187 marketplaces => \@marketplaces,
188 seller_categories => \@seller_cats,
189 has_seller_categories => scalar(@seller_cats) ? 1 : 0,
190 is_edit => 0,
191 edit_id => '',
192 page_title => 'New listing',
193 page_sub => 'One upload, every channel. Publish to your storefront and push out to the marketplaces you pick.',
194 submit_label => 'Publish',
195 just_saved => $form->{saved} ? 1 : 0,
196};
197
198# Defaults for every field so the template can reference them
199# uniformly whether we're in create or edit mode.
200my %FIELD_DEFAULTS = (
201 title => '',
202 tagline => '',
203 description => '',
204 category => '',
205 license_type => 'personal',
206 tags => '',
207 price => '',
208 price_mode => 'free',
209 visibility => 'public',
210 schedule_mode => 'now',
211 scheduled_at => '',
212 spec_parts => '',
213 spec_weight => '',
214 spec_time => '',
215 spec_difficulty => '',
216 spec_supports => '',
217 spec_formats => '',
218 ps_layer => '',
219 ps_infill => '',
220 ps_nozzle => '',
221 ps_bed => '',
222 ps_speed => '',
223 ps_walls => '',
224 ps_bed_min => '',
225 ps_resin => '',
226 tested_printers => '',
227 highlights_lines => '',
228 included_lines => '',
229 videos_lines => '',
230 # Physical product fields (kind=physical|both)
231 product_kind => 'digital',
232 physical_price => '',
233 inventory_qty_input => '', # blank = print-to-order (-1)
234 weight_grams => '',
235 dim_length_mm => '',
236 dim_width_mm => '',
237 dim_height_mm => '',
238 production_time_days=> '',
239 ship_from_country => '',
240 ship_from_postal => '',
241 shipping_options => '', # textarea: zone=cents per line
242 color_options => '',
243 material_options => '',
244);
245for my $k (keys %FIELD_DEFAULTS) {
246 $tvars->{$k} = $FIELD_DEFAULTS{$k};
247}
248# Pre-compute "checked" / "selected" stubs -- the template just drops
249# them into attribute positions ($chk_compat_fdm, $sel_cat_miniatures).
250my @COMPAT_FLAGS = qw(fdm resin sla sls mmu large);
251my @MAT_FLAGS = qw(pla petg abs tpu resin_std resin_abs nylon cf);
252my @OPT_FLAGS = qw(pre_supported support_free slicer_profiles assembly_guide);
253$tvars->{"chk_compat_$_"} = '' for @COMPAT_FLAGS;
254$tvars->{"chk_mat_$_"} = '' for @MAT_FLAGS;
255$tvars->{"chk_opt_$_"} = '' for @OPT_FLAGS;
256$tvars->{"sel_vis_$_"} = '' for qw(public unlisted private);
257$tvars->{"sel_vis_public"} = 'checked'; # default
258$tvars->{"sel_sched_$_"} = '' for qw(now scheduled patreon_first);
259$tvars->{"sel_sched_now"} = 'checked'; # default
260$tvars->{price_free_active} = 'is-active';
261$tvars->{price_paid_active} = '';
262$tvars->{price_wrap_style} = 'display:none';
263# Product-kind tabs (digital | physical | both). is-active class swaps
264# per the saved kind; physical_panel_style hides the physical fields
265# block on a pure-digital listing so the form doesn't bloat with
266# unused inputs.
267$tvars->{kind_digital_active} = 'is-active';
268$tvars->{kind_physical_active} = '';
269$tvars->{kind_both_active} = '';
270$tvars->{physical_panel_style} = 'display:none';
271$tvars->{digital_files_required} = 1; # required attribute on file input
272$tvars->{has_existing_images} = 0;
273$tvars->{existing_images} = [];
274$tvars->{has_existing_files} = 0;
275$tvars->{existing_files} = [];
276$tvars->{has_highlights} = 0;
277$tvars->{has_included} = 0;
278$tvars->{has_videos} = 0;
279$tvars->{highlights_arr} = [];
280$tvars->{included_arr} = [];
281$tvars->{videos_arr} = [];
282
283# License options -- same idea.
284my @LICENSES = (
285 [personal => 'Personal use only'],
286 [personal_attrib => 'Personal + attribution required'],
287 [commercial => 'Commercial use allowed'],
288 [cc_by => 'Creative Commons CC-BY'],
289 [cc_by_nc => 'Creative Commons CC-BY-NC'],
290 [cc_by_sa => 'Creative Commons CC-BY-SA'],
291 [cc0 => 'CC0 (Public Domain)'],
292);
293
294sub _build_options {
295 my ($items, $cur) = @_;
296 $cur = '' unless defined $cur;
297 my $html = '';
298 foreach my $row (@$items) {
299 my $sel = ($row->[0] eq $cur) ? ' selected' : '';
300 $html .= qq~<option value="$row->[0]"$sel>$row->[1]</option>~;
301 }
302 return $html;
303}
304
305my @DIFFICULTIES = (
306 [Beginner => 'Beginner'],
307 [Intermediate => 'Intermediate'],
308 [Advanced => 'Advanced'],
309 [Expert => 'Expert'],
310);
311my @SUPPORT_TYPES = (
312 ['Pre-supported' => 'Pre-supported'],
313 ['Manual supports needed' => 'Manual supports needed'],
314 ['Support-free design' => 'Support-free design'],
315);
316
317# Default options HTML (will be replaced in edit mode below).
318$tvars->{category_options_html} = _build_options(\@category_picklist, '');
319$tvars->{license_options_html} = _build_options(\@LICENSES, 'personal');
320$tvars->{difficulty_options_html} = _build_options(\@DIFFICULTIES, '');
321$tvars->{supports_options_html} = _build_options(\@SUPPORT_TYPES, '');
322
323if ($edit_id) {
324 my $dbh = $db->db_connect();
325 my $m = $db->db_readwrite($dbh,
326 qq~SELECT * FROM `${DB}`.models
327 WHERE id='$edit_id' AND user_id='$uid' AND purged_at IS NULL LIMIT 1~,
328 $ENV{SCRIPT_NAME}, __LINE__);
329 if ($m && $m->{id}) {
330 $tvars->{is_edit} = 1;
331 $tvars->{edit_id} = $m->{id};
332 $tvars->{page_title} = 'Edit listing';
333 $tvars->{page_sub} = 'Update your listing. Changes propagate everywhere this item appears.';
334 $tvars->{submit_label} = 'Save changes';
335
336 $tvars->{title} = _ha($m->{title});
337 $tvars->{tagline} = _ha($m->{tagline});
338 $tvars->{description} = _h_text($m->{description});
339 $tvars->{category} = $m->{category} || '';
340 $tvars->{license_type} = $m->{license_type} || 'personal';
341 $tvars->{tested_printers} = _ha($m->{tested_printers});
342 $tvars->{category_options_html} = _build_options(\@category_picklist, $tvars->{category});
343 $tvars->{license_options_html} = _build_options(\@LICENSES, $tvars->{license_type});
344 # specs_json key/value parsing happens further down; the
345 # difficulty/supports dropdowns are rebuilt there.
346
347 my $cents = $m->{base_price_cents} || 0;
348 if ($cents > 0) {
349 $tvars->{price_mode} = 'paid';
350 $tvars->{price} = sprintf('%.2f', $cents / 100);
351 $tvars->{price_free_active} = '';
352 $tvars->{price_paid_active} = 'is-active';
353 $tvars->{price_wrap_style} = 'display:block';
354 }
355
356 my $vis = $m->{visibility} || 'public';
357 $tvars->{"sel_vis_$_"} = '' for qw(public unlisted private);
358 $tvars->{"sel_vis_$vis"} = 'checked';
359
360 if ($m->{scheduled_publish_at}) {
361 $tvars->{"sel_sched_$_"} = '' for qw(now scheduled patreon_first);
362 $tvars->{sel_sched_scheduled} = 'checked';
363 my $when = $m->{scheduled_publish_at};
364 $when =~ s/ /T/;
365 $tvars->{scheduled_at} = $when;
366 }
367
368 # Newline-joined text -> matches what handle_post wrote.
369 $tvars->{highlights_lines} = _h_text($m->{highlights});
370 $tvars->{included_lines} = _h_text($m->{included_items});
371 $tvars->{videos_lines} = _h_text($m->{video_links});
372
373 # Build per-line arrays for [loop:] rendering in the template.
374 my $split_lines = sub {
375 my ($raw) = @_;
376 return [] unless defined $raw && length $raw;
377 my @items;
378 foreach my $line (split /\n/, $raw) {
379 $line =~ s/^\s+//; $line =~ s/\s+$//;
380 push @items, { text => _ha($line) } if length $line;
381 }
382 return \@items;
383 };
384 $tvars->{highlights_arr} = $split_lines->($m->{highlights});
385 $tvars->{included_arr} = $split_lines->($m->{included_items});
386 $tvars->{videos_arr} = $split_lines->($m->{video_links});
387 $tvars->{has_highlights} = scalar(@{$tvars->{highlights_arr}}) ? 1 : 0;
388 $tvars->{has_included} = scalar(@{$tvars->{included_arr}}) ? 1 : 0;
389 $tvars->{has_videos} = scalar(@{$tvars->{videos_arr}}) ? 1 : 0;
390
391 # Parse specs / print_settings key=val lines back into form fields.
392 my %specs = _parse_kv_lines($m->{specs_json});
393 $tvars->{spec_parts} = _ha($specs{parts});
394 $tvars->{spec_weight} = _ha($specs{weight});
395 $tvars->{spec_time} = _ha($specs{time});
396 $tvars->{spec_difficulty} = _ha($specs{difficulty});
397 $tvars->{spec_supports} = _ha($specs{supports});
398 $tvars->{spec_formats} = _ha($specs{formats});
399 # Rebuild difficulty/supports dropdowns with the saved selection.
400 $tvars->{difficulty_options_html} = _build_options(\@DIFFICULTIES, $specs{difficulty} || '');
401 $tvars->{supports_options_html} = _build_options(\@SUPPORT_TYPES, $specs{supports} || '');
402 my %ps = _parse_kv_lines($m->{print_settings_json});
403 $tvars->{ps_layer} = _ha($ps{layer});
404 $tvars->{ps_infill} = _ha($ps{infill});
405 $tvars->{ps_nozzle} = _ha($ps{nozzle});
406 $tvars->{ps_bed} = _ha($ps{bed});
407 $tvars->{ps_speed} = _ha($ps{speed});
408 $tvars->{ps_walls} = _ha($ps{walls});
409 $tvars->{ps_bed_min} = _ha($ps{bed_min});
410 $tvars->{ps_resin} = _ha($ps{resin});
411
412 # Physical product fields. Schema columns are NULL/0 for
413 # digital-only listings so existing rows pre-fill blanks.
414 my $saved_kind = $m->{product_kind} || 'digital';
415 $tvars->{product_kind} = $saved_kind;
416 $tvars->{kind_digital_active} = ($saved_kind eq 'digital') ? 'is-active' : '';
417 $tvars->{kind_physical_active} = ($saved_kind eq 'physical') ? 'is-active' : '';
418 $tvars->{kind_both_active} = ($saved_kind eq 'both') ? 'is-active' : '';
419 $tvars->{physical_panel_style} = ($saved_kind eq 'digital') ? 'display:none' : '';
420 $tvars->{digital_files_required} = ($saved_kind eq 'physical') ? 0 : 1;
421 my $phys_cents = $m->{physical_price_cents} || 0;
422 $tvars->{physical_price} = $phys_cents > 0
423 ? sprintf('%.2f', $phys_cents / 100) : '';
424 # inventory_qty: -1 = print-to-order. Surface that as blank
425 # in the form (sentinel "unlimited") so the seller doesn't
426 # see a confusing negative number; >=0 stays literal.
427 my $inv = defined $m->{inventory_qty} ? $m->{inventory_qty} : -1;
428 $tvars->{inventory_qty_input} = ($inv < 0) ? '' : $inv;
429 $tvars->{weight_grams} = $m->{weight_grams} || '';
430 $tvars->{dim_length_mm} = $m->{dim_length_mm} || '';
431 $tvars->{dim_width_mm} = $m->{dim_width_mm} || '';
432 $tvars->{dim_height_mm} = $m->{dim_height_mm} || '';
433 $tvars->{production_time_days} = $m->{production_time_days} || '';
434 $tvars->{ship_from_country} = _ha($m->{ship_from_country} || '');
435 $tvars->{ship_from_postal} = _ha($m->{ship_from_postal_code} || '');
436 $tvars->{shipping_options} = _h_text($m->{shipping_options_json} || '');
437 $tvars->{color_options} = _ha($m->{color_options} || '');
438 $tvars->{material_options} = _ha($m->{material_options} || '');
439
440 # Compat / materials / file options -- comma-separated slugs.
441 my %compat = map { $_ => 1 } split /,/, ($m->{printer_compat} || '');
442 my %mats = map { $_ => 1 } split /,/, ($m->{materials} || '');
443 my %opts = map { $_ => 1 } split /,/, ($m->{file_options} || '');
444 for my $k (@COMPAT_FLAGS) { $tvars->{"chk_compat_$k"} = $compat{"compat_$k"} ? 'checked' : ''; }
445 for my $k (@MAT_FLAGS) { $tvars->{"chk_mat_$k"} = $mats{"mat_$k"} ? 'checked' : ''; }
446 for my $k (@OPT_FLAGS) { $tvars->{"chk_opt_$k"} = $opts{"opt_$k"} ? 'checked' : ''; }
447
448 # Tags (joined back to comma list for the input).
449 my @tag_rows = $db->db_readwrite_multiple($dbh,
450 qq~SELECT tag FROM `${DB}`.model_tags WHERE model_id='$edit_id' ORDER BY tag~,
451 $ENV{SCRIPT_NAME}, __LINE__);
452 $tvars->{tags} = _ha(join(', ', map { $_->{tag} } @tag_rows));
453
454 # Existing gallery images (model_images) -- shown with X to remove.
455 my @img_rows = $db->db_readwrite_multiple($dbh,
456 qq~SELECT id, image_type, storage_key, sort_order, is_primary
457 FROM `${DB}`.model_images
458 WHERE model_id='$edit_id'
459 ORDER BY is_primary DESC, sort_order ASC, id ASC~,
460 $ENV{SCRIPT_NAME}, __LINE__);
461 my @existing_imgs;
462 foreach my $r (@img_rows) {
463 push @existing_imgs, {
464 id => $r->{id},
465 url => "/img.cgi?m=" . $r->{id},
466 is_primary => $r->{is_primary} ? 1 : 0,
467 };
468 }
469 $tvars->{existing_images} = \@existing_imgs;
470 $tvars->{has_existing_images} = scalar(@existing_imgs) ? 1 : 0;
471
472 # Existing 3D files.
473 my @file_rows = $db->db_readwrite_multiple($dbh,
474 qq~SELECT id, filename, file_type, file_size_bytes
475 FROM `${DB}`.model_files
476 WHERE model_id='$edit_id'
477 ORDER BY id ASC~,
478 $ENV{SCRIPT_NAME}, __LINE__);
479 my @existing_files;
480 foreach my $r (@file_rows) {
481 push @existing_files, {
482 id => $r->{id},
483 name => _ha($r->{filename}),
484 ext => uc($r->{file_type} || ''),
485 size_kb => sprintf('%.1f', ($r->{file_size_bytes} || 0) / 1024),
486 };
487 }
488 $tvars->{existing_files} = \@existing_files;
489 $tvars->{has_existing_files} = scalar(@existing_files) ? 1 : 0;
490 } else {
491 # Owner mismatch or missing -- bounce to fresh upload.
492 $db->db_disconnect($dbh);
493 print "Status: 302 Found\nLocation: /upload_model.cgi\n\n";
494 exit;
495 }
496 $db->db_disconnect($dbh);
497}
498
499print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
500
501my $body = join('', $tfile->template('shopcart_upload_model.html', $tvars, $userinfo));
502
503$wrap->render({
504 userinfo => $userinfo,
505 page_key => 'upload',
506 title => ($edit_id ? 'Edit listing' : 'New listing'),
507 body => $body,
508});
509exit;
510
511
512# Helpers for tvar building. _ha escapes for HTML attribute context;
513# _h_text escapes for HTML text content. Both safe for textareas.
514sub _ha {
515 my ($s) = @_;
516 $s = '' unless defined $s;
517 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
518 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
519 return $s;
520}
521sub _h_text {
522 my ($s) = @_;
523 $s = '' unless defined $s;
524 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
525 return $s;
526}
527sub _parse_kv_lines {
528 my ($raw) = @_;
529 my %out;
530 return %out unless defined $raw && length $raw;
531 foreach my $line (split /\n/, $raw) {
532 next unless $line =~ /^([^=]+)=(.*)$/;
533 my ($k, $v) = ($1, $2);
534 $k =~ s/^\s+//; $k =~ s/\s+$//;
535 $v =~ s/^\s+//; $v =~ s/\s+$//;
536 $out{$k} = $v;
537 }
538 return %out;
539}
540
541
542#======================================================================
543# Handlers
544#======================================================================
545sub handle_post {
546 # CGI->Vars joins multi-value form fields with \0. If a stray
547 # hidden _action ever sneaks back into the template alongside the
548 # draft/publish buttons, the value would be e.g. "publish\0draft"
549 # and a naive eq comparison would silently choose the wrong branch.
550 # Trim to the LAST value -- whichever submit button was clicked
551 # wins, since CGI appends button values after hidden inputs.
552 my $action = $form->{_action} || 'publish';
553 $action =~ s/.*\0//;
554 $action = lc($action); $action =~ s/[^a-z]//g;
555 my $is_draft = ($action eq 'draft') ? 1 : 0;
556
557 my $dbh = $db->db_connect();
558 unless ($dbh) { fail_redirect('database error'); }
559
560 # ---- Find or require the user's storefront ----
561 my $store = $db->db_readwrite($dbh,
562 qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~,
563 $ENV{SCRIPT_NAME}, __LINE__);
564 unless ($store && $store->{id}) {
565 $db->db_disconnect($dbh);
566 fail_redirect('no storefront found');
567 }
568 my $sid = $store->{id};
569
570 # ---- Validate + normalize fields ----
571 my $title = substr(trim($form->{title} || ''), 0, 200);
572 my $desc = $form->{description} || '';
573 if (!$is_draft && (!$title || !$desc)) {
574 $db->db_disconnect($dbh);
575 fail_redirect('title and description required');
576 }
577 $title ||= 'Untitled model';
578
579 my $tagline = substr(trim($form->{tagline} || ''), 0, 160);
580 my $category = substr(trim($form->{category} || ''), 0, 80);
581 my $license = substr(trim($form->{license_type} || 'personal'), 0, 80);
582
583 my $vis = lc($form->{visibility} || 'public');
584 $vis = 'public' unless $vis =~ /^(public|unlisted|private)$/;
585
586 my $price_mode = lc($form->{price_mode} || 'free');
587 my $price_cents = 0;
588 if ($price_mode eq 'paid') {
589 my $p = $form->{price} || 0;
590 $p =~ s/[^0-9\.]//g;
591 $price_cents = int(($p || 0) * 100);
592 }
593
594 my $slug = make_slug($title) || ('model-' . time());
595
596 # Status: draft / published / unlisted (private maps to unlisted with no public URL)
597 my $status;
598 if ($is_draft) {
599 $status = 'draft';
600 } elsif ($vis eq 'private') {
601 $status = 'unlisted';
602 } elsif ($vis eq 'unlisted') {
603 $status = 'unlisted';
604 } else {
605 $status = 'published';
606 }
607
608 # Schedule -- if scheduled, force status='draft' until the worker promotes it.
609 my $sched_mode = lc($form->{schedule_mode} || 'now');
610 my $sched_at_sql = 'NULL';
611 if ($sched_mode eq 'scheduled') {
612 my $when = $form->{scheduled_at} || '';
613 $when =~ s/T/ /; # HTML5 datetime-local => MySQL DATETIME
614 $when =~ s/[^0-9 :-]//g;
615 if ($when && length($when) >= 16) {
616 $sched_at_sql = "'$when'";
617 $status = 'draft'; # held until worker fires
618 }
619 } elsif ($sched_mode eq 'patreon_first') {
620 # Treat like 'now' here -- worker handles the 24h delay for other platforms.
621 $status = 'published' unless $is_draft;
622 }
623
624 # ---- JSON-like text columns (newline-joined for MariaDB 5.5 friendliness) ----
625 my $highlights = join_json_array($form->{highlights_json});
626 my $included = join_json_array($form->{included_json});
627 my $videos = join_json_array($form->{videos_json});
628
629 # Specs + print settings: store as key=val\n lines (simpler than JSON parse)
630 my $specs = lines_kv({
631 parts => $form->{spec_parts},
632 weight => $form->{spec_weight},
633 time => $form->{spec_time},
634 difficulty => $form->{spec_difficulty},
635 supports => $form->{spec_supports},
636 formats => $form->{spec_formats},
637 });
638 my $print_settings = lines_kv({
639 layer => $form->{ps_layer},
640 infill => $form->{ps_infill},
641 nozzle => $form->{ps_nozzle},
642 bed => $form->{ps_bed},
643 speed => $form->{ps_speed},
644 walls => $form->{ps_walls},
645 bed_min => $form->{ps_bed_min},
646 resin => $form->{ps_resin},
647 });
648
649 # Compat + materials: comma-joined slugs
650 my $compat = join_checked($form, qw(compat_fdm compat_resin compat_sla compat_sls compat_mmu compat_large));
651 my $mats = join_checked($form, qw(mat_pla mat_petg mat_abs mat_tpu mat_resin_std mat_resin_abs mat_nylon mat_cf));
652 my $opts = join_checked($form, qw(opt_pre_supported opt_support_free opt_slicer_profiles opt_assembly_guide));
653
654 my $tested_printers = substr(trim($form->{tested_printers} || ''), 0, 240);
655
656 # ---- Physical product fields ----
657 # product_kind: digital (file download only) / physical (print-and-
658 # ship only) / both (file + printed version with independent prices).
659 my $kind = lc($form->{product_kind} || 'digital');
660 $kind = 'digital' unless $kind =~ /^(digital|physical|both)$/;
661
662 my $phys_cents = 0;
663 if ($kind ne 'digital') {
664 my $p = $form->{physical_price} || 0;
665 $p =~ s/[^0-9\.]//g;
666 $phys_cents = int(($p || 0) * 100);
667 }
668
669 # inventory_qty: blank input => -1 (print-to-order). Numeric input
670 # is stored verbatim. Schema column is signed INT so -1 is valid.
671 my $inv_raw = defined($form->{inventory_qty_input})
672 ? trim($form->{inventory_qty_input}) : '';
673 my $inv_qty = -1;
674 if (length $inv_raw && $inv_raw =~ /^\d+$/) {
675 $inv_qty = int($inv_raw);
676 $inv_qty = 0 if $inv_qty < 0;
677 }
678
679 # Numeric physical fields: empty string => NULL in SQL so the
680 # column-NULL "unspecified" state is distinguishable from "0".
681 my $weight = trim($form->{weight_grams} || '');
682 my $dimL = trim($form->{dim_length_mm} || '');
683 my $dimW = trim($form->{dim_width_mm} || '');
684 my $dimH = trim($form->{dim_height_mm} || '');
685 my $prod = trim($form->{production_time_days}|| '');
686 for my $r (\$weight, \$dimL, \$dimW, \$dimH, \$prod) {
687 ${$r} =~ s/[^0-9]//g;
688 }
689 my $sql_int = sub {
690 my $v = shift;
691 return (length $v && $v =~ /^\d+$/) ? "'$v'" : 'NULL';
692 };
693
694 my $ship_country = uc(trim($form->{ship_from_country} || ''));
695 $ship_country =~ s/[^A-Z]//g;
696 $ship_country = substr($ship_country, 0, 2);
697 my $ship_postal = substr(trim($form->{ship_from_postal} || ''), 0, 20);
698
699 # shipping_options_json: textarea with zone=cents per line. Keep
700 # only valid `slug=integer` lines so a typo in one row doesn't
701 # corrupt the whole field.
702 my $ship_opts = '';
703 {
704 my $raw = $form->{shipping_options} || '';
705 my @clean;
706 foreach my $line (split /\r?\n/, $raw) {
707 $line =~ s/^\s+//; $line =~ s/\s+$//;
708 next unless $line =~ /^([A-Za-z0-9_\-]{1,40})\s*=\s*(\d+)$/;
709 push @clean, "$1=$2";
710 }
711 $ship_opts = join("\n", @clean);
712 }
713
714 my $colors = substr(trim($form->{color_options} || ''), 0, 255);
715 my $materials_phys = substr(trim($form->{material_options} || ''), 0, 255);
716
717 # ---- Edit-mode detection -----------------------------------
718 # When ?model_id=N (or hidden model_id) is set, this is an EDIT
719 # not a fresh upload. Verify ownership before letting any UPDATE
720 # touch the row.
721 my $edit_id = $form->{model_id} || 0;
722 $edit_id =~ s/[^0-9]//g;
723 my $model_id = 0;
724 if ($edit_id) {
725 my $owner = $db->db_readwrite($dbh,
726 qq~SELECT user_id, purged_at FROM `${DB}`.models WHERE id='$edit_id' LIMIT 1~,
727 $ENV{SCRIPT_NAME}, __LINE__);
728 unless ($owner && $owner->{user_id} && $owner->{user_id} eq $uid) {
729 $db->db_disconnect($dbh);
730 fail_redirect('model not yours');
731 }
732 if ($owner->{purged_at}) {
733 $db->db_disconnect($dbh);
734 fail_redirect('model has been permanently deleted');
735 }
736 $model_id = $edit_id;
737 }
738
739 # ---- Escape everything ----
740 my @to_escape = (\$title, \$slug, \$desc, \$tagline, \$category, \$license, \$vis,
741 \$highlights, \$included, \$videos, \$specs, \$print_settings,
742 \$compat, \$mats, \$opts, \$tested_printers, \$status,
743 \$kind, \$ship_country, \$ship_postal, \$ship_opts,
744 \$colors, \$materials_phys);
745 for my $r (@to_escape) { ${$r} =~ s/[\\']/\\$&/g; }
746
747 # NULL-or-quoted helpers for the physical-product columns. Required
748 # because empty numeric fields must reach the DB as NULL, not '0'.
749 my $weight_sql = $sql_int->($weight);
750 my $dimL_sql = $sql_int->($dimL);
751 my $dimW_sql = $sql_int->($dimW);
752 my $dimH_sql = $sql_int->($dimH);
753 my $prod_sql = $sql_int->($prod);
754 my $country_sql = length $ship_country == 2 ? "'$ship_country'" : 'NULL';
755 my $postal_sql = length $ship_postal ? "'$ship_postal'" : 'NULL';
756
757 if ($model_id) {
758 # ---- UPDATE existing model row ----
759 # slug stays unchanged on edits -- changing it would break any
760 # external links pointing at the old slug. created_at is also
761 # preserved.
762 $db->db_readwrite($dbh, qq~
763 UPDATE `${DB}`.models SET
764 title = '$title',
765 description = '$desc',
766 tagline = '$tagline',
767 base_price_cents = '$price_cents',
768 category = '$category',
769 license_type = '$license',
770 status = '$status',
771 visibility = '$vis',
772 highlights = '$highlights',
773 included_items = '$included',
774 video_links = '$videos',
775 specs_json = '$specs',
776 print_settings_json = '$print_settings',
777 printer_compat = '$compat',
778 materials = '$mats',
779 file_options = '$opts',
780 tested_printers = '$tested_printers',
781 product_kind = '$kind',
782 physical_price_cents = '$phys_cents',
783 inventory_qty = '$inv_qty',
784 weight_grams = $weight_sql,
785 dim_length_mm = $dimL_sql,
786 dim_width_mm = $dimW_sql,
787 dim_height_mm = $dimH_sql,
788 production_time_days = $prod_sql,
789 ship_from_country = $country_sql,
790 ship_from_postal_code= $postal_sql,
791 shipping_options_json= '$ship_opts',
792 color_options = '$colors',
793 material_options = '$materials_phys',
794 scheduled_publish_at = $sched_at_sql,
795 updated_at = NOW()
796 WHERE id = '$model_id' AND user_id = '$uid' LIMIT 1
797 ~, $ENV{SCRIPT_NAME}, __LINE__);
798
799 # Tag replacement: drop existing tags, insert the new set.
800 # That way removing a tag in the form actually removes it.
801 $db->db_readwrite($dbh,
802 qq~DELETE FROM `${DB}`.model_tags WHERE model_id='$model_id'~,
803 $ENV{SCRIPT_NAME}, __LINE__);
804 } else {
805 # ---- INSERT a fresh model row ----
806 $db->db_readwrite($dbh, qq~
807 INSERT INTO `${DB}`.models SET
808 user_id = '$uid',
809 title = '$title',
810 slug = '$slug',
811 description = '$desc',
812 tagline = '$tagline',
813 base_price_cents = '$price_cents',
814 currency = 'USD',
815 category = '$category',
816 license_type = '$license',
817 status = '$status',
818 visibility = '$vis',
819 highlights = '$highlights',
820 included_items = '$included',
821 video_links = '$videos',
822 specs_json = '$specs',
823 print_settings_json = '$print_settings',
824 printer_compat = '$compat',
825 materials = '$mats',
826 file_options = '$opts',
827 tested_printers = '$tested_printers',
828 product_kind = '$kind',
829 physical_price_cents = '$phys_cents',
830 inventory_qty = '$inv_qty',
831 weight_grams = $weight_sql,
832 dim_length_mm = $dimL_sql,
833 dim_width_mm = $dimW_sql,
834 dim_height_mm = $dimH_sql,
835 production_time_days = $prod_sql,
836 ship_from_country = $country_sql,
837 ship_from_postal_code= $postal_sql,
838 shipping_options_json= '$ship_opts',
839 color_options = '$colors',
840 material_options = '$materials_phys',
841 scheduled_publish_at = $sched_at_sql,
842 created_at = NOW(),
843 updated_at = NOW()
844 ~, $ENV{SCRIPT_NAME}, __LINE__);
845
846 my $row = $db->db_readwrite($dbh,
847 qq~SELECT id FROM `${DB}`.models WHERE user_id='$uid' AND slug='$slug' ORDER BY id DESC LIMIT 1~,
848 $ENV{SCRIPT_NAME}, __LINE__);
849 $model_id = $row && $row->{id} ? $row->{id} : 0;
850 unless ($model_id) {
851 $db->db_disconnect($dbh);
852 fail_redirect('insert failed');
853 }
854 }
855
856 # ---- Seller-defined categories ----
857 # `seller_cat_ids` arrives as a multi-value form field (CGI->Vars
858 # joins with \0). Whatever the seller checked replaces the prior
859 # set; unchecked means "no longer in this category".
860 if ($catmod->schema_ready($db, $dbh, $DB)) {
861 my $raw = $form->{seller_cat_ids};
862 my @ids;
863 if (ref $raw eq 'ARRAY') { @ids = @$raw; }
864 else { @ids = grep { length } split /\0/, ($raw // ''); }
865 $catmod->assign($db, $dbh, $DB, $model_id, \@ids, $uid);
866 }
867
868 # ---- Tags (re-insert for both create and edit) ----
869 my $raw_tags = $form->{tags} || '';
870 foreach my $tag (split /,/, $raw_tags) {
871 $tag = trim($tag);
872 $tag =~ s/'/\\'/g;
873 next if !$tag || length($tag) > 64;
874 $db->db_readwrite($dbh,
875 qq~INSERT IGNORE INTO `${DB}`.model_tags SET model_id='$model_id', tag='$tag'~,
876 $ENV{SCRIPT_NAME}, __LINE__);
877 }
878
879 # ---- Auto-list on the creator's storefront (idempotent) ----
880 # On creation, push the model to the top of the storefront so it
881 # becomes the spotlight feature. On edit, only ensure a listing
882 # row exists when the model just went published; do not re-promote
883 # an already-listed model -- that would clobber the user's manual
884 # sort_order.
885 if (!$is_draft && $status eq 'published') {
886 my $top_sort = 0;
887 my $r = $db->db_readwrite($dbh,
888 qq~SELECT COALESCE(MIN(sort_order), 0) - 1 AS new_top
889 FROM `${DB}`.storefront_listings
890 WHERE storefront_id='$sid'~,
891 $ENV{SCRIPT_NAME}, __LINE__);
892 $top_sort = $r->{new_top} if $r && defined $r->{new_top};
893 $db->db_readwrite($dbh, qq~
894 INSERT IGNORE INTO `${DB}`.storefront_listings SET
895 storefront_id = '$sid',
896 model_id = '$model_id',
897 visible = 1,
898 override_price_cents = NULL,
899 sort_order = '$top_sort',
900 added_at = NOW()
901 ~, $ENV{SCRIPT_NAME}, __LINE__);
902 }
903
904 # ---- Save uploaded model files ----
905 save_uploaded_files($dbh, $sid, $model_id, 'model_files', 'model_files', qr/\.(stl|obj|step|stp|3mf|zip|rar)$/i);
906 save_uploaded_files($dbh, $sid, $model_id, 'model_images', 'model_images', qr/\.(jpg|jpeg|png|gif|webp)$/i);
907
908 # ---- Queue marketplace pushes ----
909 # DBConnect's error() helper calls exit() on any SQL failure (eval
910 # can't catch exit), so we have to verify the table exists before
911 # inserting. Until the 5.6 migration is run on a given server,
912 # marketplace_pushes won't exist and we silently skip the queue --
913 # the model itself still publishes fine.
914 my $have_pushes_table = 0;
915 {
916 # information_schema instead of SHOW TABLES LIKE: the latter
917 # plus DBConnect autoviv produces a truthy hashref even when
918 # the table is absent, which would let an INSERT fall through
919 # to a "Table doesn't exist" 500.
920 my $row = $db->db_readwrite($dbh, qq~
921 SELECT COUNT(*) AS n FROM information_schema.tables
922 WHERE table_schema='$DB' AND table_name='marketplace_pushes'
923 ~, $ENV{SCRIPT_NAME}, __LINE__);
924 $have_pushes_table = 1 if $row && $row->{n};
925 }
926
927 my @selected;
928 if (ref $form->{'publish_to'} eq 'ARRAY') {
929 @selected = @{ $form->{'publish_to'} };
930 } else {
931 # CGI->Vars joins multi-values with "\0" — split here just in case.
932 my $raw = $form->{'publish_to'} || '';
933 @selected = grep { length } split /\0/, $raw;
934 }
935 if ($is_demo && scalar(@selected) > $demo_max) {
936 @selected = @selected[0 .. $demo_max - 1];
937 }
938 # Only queue marketplace pushes on first upload -- editing an
939 # existing model shouldn't re-queue identical push jobs. The
940 # adapter/worker will pull updated fields from the model row at
941 # send time. Physical-only listings have no STL files to push,
942 # so skip them entirely; 'both' listings still push the digital
943 # half (the printed copy is a shop.3dshawn.com-only offering).
944 if ($have_pushes_table && !$edit_id && $kind ne 'physical') {
945 foreach my $slug (@selected) {
946 $slug = lc($slug); $slug =~ s/[^a-z0-9]//g;
947 next unless $slug;
948 my $platform_exists = $mp->by_slug($slug);
949 next unless $platform_exists;
950 $db->db_readwrite($dbh, qq~
951 INSERT INTO `${DB}`.marketplace_pushes SET
952 user_id = '$uid',
953 model_id = '$model_id',
954 platform = '$slug',
955 status = 'queued',
956 created_at = NOW()
957 ~, $ENV{SCRIPT_NAME}, __LINE__);
958 }
959 }
960
961 $db->db_disconnect($dbh);
962
963 # ---- Redirect ----
964 # On EDIT, return to the edit page so the user sees their changes
965 # reflected and can continue tweaking. On CREATE, go to the Models
966 # list so they see the new card alongside any existing ones.
967 if ($edit_id) {
968 print "Status: 302 Found\nLocation: /upload_model.cgi?model_id=$model_id&saved=1\n\n";
969 } else {
970 my $kind = $is_draft ? 'draft_saved' : 'published';
971 print "Status: 302 Found\nLocation: /models.cgi?$kind=$model_id\n\n";
972 }
973 return;
974}
975
976#======================================================================
977# Helpers
978#======================================================================
979sub trim {
980 my ($s) = @_;
981 return '' unless defined $s;
982 $s =~ s/^\s+//; $s =~ s/\s+$//;
983 return $s;
984}
985
986sub make_slug {
987 my ($s) = @_;
988 return '' unless defined $s;
989 $s = lc($s);
990 $s =~ s/[^a-z0-9]+/-/g;
991 $s =~ s/^-+|-+$//g;
992 return substr($s, 0, 120);
993}
994
995# Pull a JSON-array hidden field and convert to newline-joined string.
996sub join_json_array {
997 my ($raw) = @_;
998 return '' unless defined $raw && length $raw;
999 # very small JSON parser -- handles `["a","b"]` only.
1000 return '' unless $raw =~ /^\s*\[(.*)\]\s*$/s;
1001 my $inner = $1;
1002 my @items;
1003 while ($inner =~ /"((?:\\.|[^"\\])*)"/g) {
1004 my $v = $1;
1005 $v =~ s/\\"/"/g;
1006 $v =~ s/\\\\/\\/g;
1007 push @items, $v if length $v;
1008 }
1009 return join("\n", @items);
1010}
1011
1012sub lines_kv {
1013 my ($h) = @_;
1014 my @out;
1015 foreach my $k (sort keys %$h) {
1016 my $v = $h->{$k};
1017 next unless defined $v && length $v;
1018 $v =~ s/[\r\n]/ /g;
1019 push @out, "$k=$v";
1020 }
1021 return join("\n", @out);
1022}
1023
1024sub join_checked {
1025 my ($form, @keys) = @_;
1026 my @on;
1027 foreach my $k (@keys) {
1028 push @on, $k if $form->{$k};
1029 }
1030 return join(',', @on);
1031}
1032
1033sub save_uploaded_files {
1034 my ($dbh, $sid, $model_id, $field, $table, $ext_re) = @_;
1035
1036 # CGI.pm keys uploads by the LITERAL form-field name. If the
1037 # template ever sends `name="model_images[]"` (PHP-style) the
1038 # files land under the key "model_images[]", not "model_images".
1039 # Try both so the upload pipeline never silently drops files.
1040 my @uploads = $q->upload($field);
1041 @uploads = $q->upload($field . '[]') unless @uploads;
1042 return unless @uploads;
1043
1044 # Plan-aware per-file + total-storage gate. We pre-check each
1045 # uploaded file's temp size BEFORE copying any bytes so a rejected
1046 # file leaves no on-disk residue. On rejection we redirect with a
1047 # readable error so the seller knows why their upload bounced.
1048 my $sto = MODS::ShopCart::Storage->new;
1049
1050 my $base_dir = '/var/www/vhosts/3dshawn.com/shop.3dshawn.com/uploads';
1051 my $models_dir = "$base_dir/models";
1052 my $store_dir = "$models_dir/$sid";
1053 my $dest_dir = "$store_dir/$model_id";
1054 foreach my $d ($base_dir, $models_dir, $store_dir, $dest_dir) {
1055 unless (-d $d) { mkdir $d, 0755 or return; }
1056 }
1057
1058 my $order = 0;
1059 foreach my $u (@uploads) {
1060 next unless $u;
1061 my $orig = "$u";
1062 my $tmp = $q->tmpFileName($u);
1063 next unless $tmp && -f $tmp;
1064 my $size = -s $tmp;
1065 next unless $size && $size > 0;
1066 $orig =~ s/.*[\\\/]//; # strip path components
1067 $orig =~ s/[^A-Za-z0-9._-]/_/g; # safe filename
1068 next unless $orig =~ $ext_re;
1069 my $ext = lc($1);
1070
1071 # Storage cap gate -- per-file + total-storage. On reject we
1072 # redirect back to the upload form with a readable message.
1073 my ($ok, $cap_err) = $sto->check_upload_allowed($db, $dbh, $DB, $uid, $size);
1074 unless ($ok) {
1075 $db->db_disconnect($dbh);
1076 fail_redirect($cap_err || 'upload rejected by storage policy');
1077 }
1078
1079 my $id = random_id(16);
1080 my $dest_name = "$id.$ext";
1081 my $dest_path = "$dest_dir/$dest_name";
1082
1083 open(my $in, '<', $tmp) or next;
1084 open(my $out, '>', $dest_path) or do { close $in; next; };
1085 binmode $in; binmode $out;
1086 while (read($in, my $buf, 8192)) { print $out $buf; }
1087 close $in; close $out;
1088
1089 # File is now committed to disk -- bump the denormalized total
1090 # so the next file in this batch sees the running cap usage and
1091 # so the next page-load reflects accurate storage state.
1092 $sto->bump_usage($db, $dbh, $DB, $uid, $size);
1093
1094 my $rel_key = "uploads/models/$sid/$model_id/$dest_name";
1095
1096 if ($table eq 'model_files') {
1097 my $safe_orig = $orig; $safe_orig =~ s/[\\']/\\$&/g;
1098 my $checksum = '0' x 64; # real checksum is a worker job
1099 $db->db_readwrite($dbh, qq~
1100 INSERT INTO `${DB}`.model_files SET
1101 model_id = '$model_id',
1102 filename = '$safe_orig',
1103 file_type = '$ext',
1104 file_size_bytes = '$size',
1105 storage_provider = 'local',
1106 storage_key = '$rel_key',
1107 checksum_sha256 = '$checksum',
1108 scan_status = 'pending',
1109 uploaded_at = NOW()
1110 ~, $ENV{SCRIPT_NAME}, __LINE__);
1111 } else {
1112 my $is_primary = ($order == 0) ? 1 : 0;
1113 my $type = ($order == 0) ? 'hero' : 'gallery';
1114 $db->db_readwrite($dbh, qq~
1115 INSERT INTO `${DB}`.model_images SET
1116 model_id = '$model_id',
1117 image_type = '$type',
1118 storage_key = '$rel_key',
1119 sort_order = '$order',
1120 is_primary = '$is_primary',
1121 created_at = NOW()
1122 ~, $ENV{SCRIPT_NAME}, __LINE__);
1123
1124 # First gallery image becomes the model's hero so the
1125 # storefront + models page render it instead of falling
1126 # back to the stock placeholder. The image is served via
1127 # /img.cgi?m=<model_images.id>.
1128 if ($order == 0) {
1129 my $r = $db->db_readwrite($dbh, qq~
1130 SELECT id FROM `${DB}`.model_images
1131 WHERE model_id='$model_id' AND storage_key='$rel_key'
1132 ORDER BY id DESC LIMIT 1
1133 ~, $ENV{SCRIPT_NAME}, __LINE__);
1134 if ($r && $r->{id}) {
1135 my $url = "/img.cgi?m=" . $r->{id};
1136 $db->db_readwrite($dbh, qq~
1137 UPDATE `${DB}`.models
1138 SET hero_image_url = '$url'
1139 WHERE id = '$model_id' LIMIT 1
1140 ~, $ENV{SCRIPT_NAME}, __LINE__);
1141 }
1142 }
1143 }
1144 $order++;
1145 }
1146}
1147
1148sub random_id {
1149 my ($len) = @_;
1150 my @hex = ('0'..'9', 'a'..'f');
1151 my $id = '';
1152 $id .= $hex[int(rand(@hex))] for 1..$len;
1153 return $id;
1154}
1155
1156sub fail_redirect {
1157 my ($msg) = @_;
1158 $msg ||= 'unknown error';
1159 $msg =~ s/[^A-Za-z0-9 _.-]/ /g;
1160 $msg =~ s/ /+/g;
1161 print "Status: 302 Found\nLocation: /upload_model.cgi?error=$msg\n\n";
1162 exit;
1163}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help