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

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/_attic/upload_model.cgi

added on local at 2026-07-01 21:47:37

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