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

O Operator
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/support.cgi

added on local at 2026-07-11 18:33:40

Added
+670
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 54475066ac72
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# ---- pre-header die catcher for JSON entries (poll/send/upload) -----
3# When entry is one of the JSON endpoints and a module load/die happens
4# before headers are printed, turn it into a valid JSON 500 so the JS
5# client sees the real cause instead of Apache's "End of script output
6# before headers." Skipped for the HTML entries where the wrapper is
7# free to render an error page.
8my $__sp_started = 0;
9my $__sp_entry_json = 0;
10BEGIN {
11 my $sn = $ENV{SCRIPT_NAME} || '';
12 $__sp_entry_json = ($sn =~ m{/(support_poll|support_send|support_upload)\.cgi$}) ? 1 : 0;
13 if ($__sp_entry_json) {
14 $SIG{__DIE__} = sub {
15 die @_ if $^S; # inside eval -- let it propagate
16 my $err = $_[0]; chomp $err;
17 $err =~ s/\\/\\\\/g; $err =~ s/"/\\"/g; $err =~ s/[\r\n]+/ | /g;
18 unless ($__sp_started) {
19 print "Status: 500\nContent-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
20 $__sp_started = 1;
21 }
22 print qq~{"ok":false,"error":"die: $err"}~;
23 exit;
24 };
25 }
26}
27
28#======================================================================
29# RePricer -- customer support center + JSON endpoints.
30#
31# Dispatches on SCRIPT_NAME:
32# support.cgi -> HTML two-pane thread view (list + reply form)
33# support_poll.cgi -> JSON: fetch new messages in a thread
34# support_send.cgi -> POST: send a reply into a thread
35# support_upload.cgi -> POST: multipart attachment upload (JSON reply)
36# support_file.cgi -> GET: stream an attachment's bytes
37#
38# HTML routes:
39# GET /support.cgi -- list view, no thread open
40# GET /support.cgi?thread_id=N -- list + open thread N
41# GET /support.cgi?new=1 -- list + show "New request" form
42# POST /support.cgi action=create_thread -- open a new ticket
43#
44# Consolidated: former /support_file.cgi, /support_poll.cgi,
45# /support_send.cgi, /support_upload.cgi are now wrappers that do this
46# file; SCRIPT_NAME routes execution.
47#======================================================================
48use strict;
49use warnings;
50
51use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com';
52use CGI;
53
54# POST_MAX guards multipart body size; apply for all entries so
55# support_upload.cgi's 12MB cap is in force whichever entry is hit.
56$CGI::POST_MAX = 12 * 1024 * 1024;
57
58use MODS::Template;
59use MODS::DBConnect;
60use MODS::Login;
61use MODS::RePricer::Config;
62use MODS::RePricer::Wrapper;
63use MODS::RePricer::Support;
64
65$| = 1;
66
67my $q = CGI->new;
68my $form = $q->Vars;
69my $tfile = MODS::Template->new;
70my $db = MODS::DBConnect->new;
71my $auth = MODS::Login->new;
72my $config = MODS::RePricer::Config->new;
73my $wrap = MODS::RePricer::Wrapper->new;
74my $sup = MODS::RePricer::Support->new;
75my $DB = $config->settings('database_name');
76
77my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'support';
78
79if ($entry eq 'support_poll') { _sup_poll(); exit; }
80elsif ($entry eq 'support_send') { _sup_send(); exit; }
81elsif ($entry eq 'support_upload') { _sup_upload(); exit; }
82elsif ($entry eq 'support_file') { _sup_file(); exit; }
83
84_sup_main();
85exit;
86
87#======================================================================
88# Primary HTML thread view (support.cgi)
89#======================================================================
90sub _sup_main {
91 my $userinfo = $auth->login_verify();
92 unless ($userinfo) {
93 print "Status: 302 Found\nLocation: /login.cgi?return=/support.cgi\n\n";
94 return;
95 }
96
97 my $uid = $userinfo->{user_id};
98 $uid =~ s/[^0-9]//g;
99
100 my $dbh = $db->db_connect();
101
102 # ---- POST handlers (PRG pattern) ---------------------------------
103 if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
104 my $action = $form->{action} || '';
105 if ($action eq 'create_thread') {
106 my $subject = $form->{subject} || '';
107 my $body = $form->{body} || '';
108 $subject =~ s/^\s+|\s+$//g; $body =~ s/^\s+|\s+$//g;
109 if (length $subject && length $body) {
110 my $tid = $sup->create_thread($db, $dbh, $DB, {
111 user_id => $uid, subject => $subject, body => $body,
112 });
113 if ($tid) {
114 my @ids = grep { /^[a-zA-Z0-9]{16}$/ } $q->multi_param('attach_id');
115 if (@ids) {
116 my $first_msg = $db->db_readwrite($dbh,
117 qq~SELECT id FROM `${DB}`.support_messages
118 WHERE thread_id='$tid' ORDER BY id ASC LIMIT 1~,
119 $ENV{SCRIPT_NAME}, __LINE__);
120 my $mid = ($first_msg && $first_msg->{id}) ? $first_msg->{id} : 0;
121 if ($mid) {
122 my $in_list = join(',', map { "'$_'" } @ids);
123 $db->db_readwrite($dbh, qq~
124 UPDATE `${DB}`.support_attachments
125 SET thread_id='$tid', message_id='$mid'
126 WHERE id IN ($in_list)
127 AND user_id='$uid'
128 AND thread_id IS NULL
129 ~, $ENV{SCRIPT_NAME}, __LINE__);
130 }
131 }
132 $db->db_disconnect($dbh);
133 print "Status: 302 Found\nLocation: /support.cgi?thread_id=$tid&ok=opened\n\n";
134 return;
135 }
136 }
137 $db->db_disconnect($dbh);
138 print "Status: 302 Found\nLocation: /support.cgi?new=1&err=missing\n\n";
139 return;
140 }
141 }
142
143 # ---- GET render --------------------------------------------------
144 my $threads = $sup->list_threads_for_customer($db, $dbh, $DB, $uid);
145
146 my $active_tid = $form->{thread_id} || 0;
147 $active_tid =~ s/[^0-9]//g;
148 my $active_thread;
149 my $messages = [];
150 if ($active_tid) {
151 $active_thread = $sup->load_thread($db, $dbh, $DB, $active_tid);
152 if (!$active_thread || $active_thread->{customer_user_id} ne $uid) {
153 $active_thread = undef;
154 $active_tid = 0;
155 } else {
156 $messages = $sup->load_messages($db, $dbh, $DB, $active_tid);
157 $sup->mark_read($db, $dbh, $DB, $active_tid, 'customer') if $active_thread->{customer_unread};
158 }
159 }
160
161 $db->db_disconnect($dbh);
162
163 my @thread_tiles;
164 foreach my $t (@$threads) {
165 my $is_active = ($active_tid && $t->{id} eq $active_tid) ? 1 : 0;
166 push @thread_tiles, {
167 id => $t->{id},
168 subject => _sup_h($t->{subject}),
169 status => $t->{status},
170 status_label => _sup_status_label($t->{status}),
171 status_class => _sup_status_class($t->{status}),
172 unread => $t->{customer_unread} || 0,
173 has_unread => ($t->{customer_unread} && !$is_active) ? 1 : 0,
174 is_active => $is_active,
175 last_at => _sup_humanize_ts($t->{last_message_at}),
176 };
177 }
178
179 my @message_rows;
180 if ($active_thread) {
181 my $atts_by_msg = {};
182 my $dbh_a = $db->db_connect();
183 if ($dbh_a) {
184 my @rows = $db->db_readwrite_multiple($dbh_a, qq~
185 SELECT id, message_id, filename, mime_type, size_bytes
186 FROM `${DB}`.support_attachments
187 WHERE thread_id='$active_tid'
188 ORDER BY uploaded_at ASC
189 ~, $ENV{SCRIPT_NAME}, __LINE__);
190 $db->db_disconnect($dbh_a);
191 for my $a (@rows) {
192 push @{ $atts_by_msg->{ $a->{message_id} } }, {
193 id => $a->{id},
194 url => "/support_file.cgi?id=$a->{id}",
195 name => $a->{filename},
196 };
197 }
198 }
199 foreach my $m (@$messages) {
200 my $role = $m->{sender_role};
201 my $atts = $atts_by_msg->{ $m->{id} } || [];
202 push @message_rows, {
203 id => $m->{id},
204 body_html => _sup_body_html($m->{body}),
205 ts => _sup_humanize_ts($m->{created_at}),
206 ts_iso => $m->{created_at},
207 sender_name => _sup_h($m->{sender_name} || ($role eq 'system' ? 'RePricer' : 'Anonymous')),
208 sender_initial => uc(substr($m->{sender_name} || ($role eq 'admin' ? 'W' : 'C'), 0, 1)),
209 is_customer => ($role eq 'customer') ? 1 : 0,
210 is_admin => ($role eq 'admin') ? 1 : 0,
211 is_system => ($role eq 'system') ? 1 : 0,
212 attachments => $atts,
213 has_attach => scalar(@$atts) ? 1 : 0,
214 };
215 }
216 }
217
218 my $tvars = {
219 threads => \@thread_tiles,
220 has_threads => scalar(@thread_tiles) ? 1 : 0,
221 thread_count => scalar(@thread_tiles),
222
223 has_active => $active_thread ? 1 : 0,
224 active_id => $active_thread ? $active_thread->{id} : 0,
225 active_subject => $active_thread ? _sup_h($active_thread->{subject}) : '',
226 active_status => $active_thread ? _sup_status_label($active_thread->{status}) : '',
227 active_status_class => $active_thread ? _sup_status_class($active_thread->{status}) : '',
228 messages => \@message_rows,
229 has_messages => scalar(@message_rows) ? 1 : 0,
230 last_message_id => scalar(@message_rows) ? $message_rows[-1]->{id} : 0,
231
232 show_new_form => $form->{new} ? 1 : 0,
233 new_err => $form->{err} || '',
234 has_new_err => ($form->{err}) ? 1 : 0,
235 just_opened => (($form->{ok} || '') eq 'opened') ? 1 : 0,
236 };
237
238 my $body = join('', $tfile->template('repricer_support.html', $tvars, $userinfo));
239
240 print "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";
241
242 $wrap->render({
243 userinfo => $userinfo,
244 page_key => 'support',
245 title => 'Support',
246 body => $body,
247 });
248}
249
250#======================================================================
251# support_poll.cgi -- JSON poll for new messages in a thread
252#======================================================================
253sub _sup_poll {
254 my $fail = sub {
255 my ($code, $msg) = @_;
256 print "Status: $code\nContent-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
257 $msg =~ s/"/\\"/g;
258 print qq~{"ok":false,"error":"$msg"}~;
259 };
260
261 my $userinfo = $auth->login_verify();
262 unless ($userinfo && $userinfo->{user_id}) { $fail->(401, 'not authenticated'); return; }
263
264 my $uid = $userinfo->{user_id};
265 $uid =~ s/[^0-9]//g;
266 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
267
268 my $tid = $form->{thread_id} || 0; $tid =~ s/[^0-9]//g;
269 unless ($tid) { $fail->(400, 'missing thread_id'); return; }
270 my $since = $form->{since} || 0; $since =~ s/[^0-9]//g;
271
272 my $dbh = $db->db_connect();
273 my $thread = $sup->load_thread($db, $dbh, $DB, $tid);
274 unless ($thread && $thread->{id}) {
275 $db->db_disconnect($dbh);
276 $fail->(404, 'thread not found'); return;
277 }
278 unless ($is_admin || $thread->{customer_user_id} eq $uid) {
279 $db->db_disconnect($dbh);
280 $fail->(403, 'not your thread'); return;
281 }
282
283 my $rows = $sup->load_messages($db, $dbh, $DB, $tid, $since);
284
285 my %atts_by_msg;
286 if (@$rows) {
287 my @msg_ids = map { $_->{id} } @$rows;
288 my $have_at = $db->db_readwrite($dbh, qq~
289 SELECT COUNT(*) AS n FROM information_schema.tables
290 WHERE table_schema='$DB' AND table_name='support_attachments'
291 ~, $ENV{SCRIPT_NAME}, __LINE__);
292 if ($have_at && $have_at->{n}) {
293 my $in_list = join(',', map { "'$_'" } @msg_ids);
294 my @arows = $db->db_readwrite_multiple($dbh, qq~
295 SELECT id, message_id, filename
296 FROM `${DB}`.support_attachments
297 WHERE message_id IN ($in_list)
298 ORDER BY uploaded_at ASC
299 ~, $ENV{SCRIPT_NAME}, __LINE__);
300 for my $a (@arows) {
301 push @{ $atts_by_msg{ $a->{message_id} } }, {
302 id => $a->{id},
303 url => "/support_file.cgi?id=$a->{id}",
304 name => $a->{filename},
305 };
306 }
307 }
308 }
309
310 my $side = $is_admin ? 'admin' : 'customer';
311 $sup->mark_read($db, $dbh, $DB, $tid, $side);
312
313 my $fresh = $sup->load_thread($db, $dbh, $DB, $tid);
314 $db->db_disconnect($dbh);
315
316 my $last_id = 0;
317 my @msg_json;
318 foreach my $m (@$rows) {
319 $last_id = $m->{id} if $m->{id} > $last_id;
320 my $body = $m->{body};
321 $body =~ s/&/&amp;/g; $body =~ s/</&lt;/g; $body =~ s/>/&gt;/g;
322 $body =~ s/"/&quot;/g; $body =~ s/'/&#39;/g;
323 $body =~ s/\r//g; $body =~ s/\n/<br>/g;
324
325 my $name = $m->{sender_name} || ($m->{sender_role} eq 'system' ? 'RePricer' : 'User');
326 my $atts = $atts_by_msg{ $m->{id} } || [];
327 if (@$atts) {
328 $body .= '<div class="sup-msg-atts">';
329 for my $a (@$atts) {
330 my $aid = $a->{id};
331 $body .= qq~<a href="/support_file.cgi?id=$aid" target="_blank" rel="noopener" class="sup-msg-att"><img src="/support_file.cgi?id=$aid" alt=""></a>~;
332 }
333 $body .= '</div>';
334 }
335 push @msg_json, _sup_json_obj({
336 id => $m->{id} + 0,
337 sender_role => $m->{sender_role},
338 sender_name => $name,
339 body_html => $body,
340 ts => $m->{created_at} || '',
341 });
342 }
343 $last_id = $since if $last_id < $since;
344
345 my $thread_status = $fresh ? ($fresh->{status} || '') : '';
346
347 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
348 print "{\"ok\":true,";
349 print "\"thread_id\":$tid,";
350 print "\"last_id\":$last_id,";
351 print "\"status\":\"" . _sup_json_str($thread_status) . "\",";
352 print "\"messages\":[" . join(',', @msg_json) . "]";
353 print "}";
354}
355
356#======================================================================
357# support_send.cgi -- POST reply into an existing thread
358#======================================================================
359sub _sup_send {
360 my $async = $form->{async} ? 1 : 0;
361
362 my $fail = sub {
363 my ($code, $msg) = @_;
364 if ($async) {
365 print "Status: $code\nContent-Type: application/json\n\n";
366 $msg =~ s/"/\\"/g;
367 print qq~{"ok":false,"error":"$msg"}~;
368 } else {
369 print "Status: $code\nContent-Type: text/plain\n\n$msg\n";
370 }
371 };
372
373 my $userinfo = $auth->login_verify();
374 unless ($userinfo && $userinfo->{user_id}) { $fail->(401, 'not authenticated'); return; }
375
376 my $uid = $userinfo->{user_id};
377 $uid =~ s/[^0-9]//g;
378 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
379
380 my $tid = $form->{thread_id} || 0;
381 $tid =~ s/[^0-9]//g;
382 unless ($tid) { $fail->(400, 'missing thread_id'); return; }
383
384 my $body = $form->{body} || '';
385 $body =~ s/^\s+|\s+$//g;
386 unless (length $body) { $fail->(400, 'empty message'); return; }
387 $body = substr($body, 0, 50000);
388
389 my $dbh = $db->db_connect();
390
391 my $thread = $sup->load_thread($db, $dbh, $DB, $tid);
392 unless ($thread && $thread->{id}) {
393 $db->db_disconnect($dbh);
394 $fail->(404, 'thread not found'); return;
395 }
396
397 my $sender_role;
398 if ($is_admin) {
399 $sender_role = 'admin';
400 } else {
401 if ($thread->{customer_user_id} ne $uid) {
402 $db->db_disconnect($dbh);
403 $fail->(403, 'not your thread'); return;
404 }
405 $sender_role = 'customer';
406 }
407
408 my $new_msg_id = $sup->send_message($db, $dbh, $DB, {
409 thread_id => $tid,
410 sender_role => $sender_role,
411 sender_user_id => $uid,
412 body => $body,
413 });
414
415 my @ids = grep { /^[a-zA-Z0-9]{16}$/ } $q->multi_param('attach_id');
416 if (@ids && $new_msg_id) {
417 my $in_list = join(',', map { "'$_'" } @ids);
418 $db->db_readwrite($dbh, qq~
419 UPDATE `${DB}`.support_attachments
420 SET thread_id='$tid', message_id='$new_msg_id'
421 WHERE id IN ($in_list)
422 AND user_id='$uid'
423 AND thread_id IS NULL
424 ~, $ENV{SCRIPT_NAME}, __LINE__);
425 }
426
427 $sup->mark_read($db, $dbh, $DB, $tid, $sender_role);
428
429 $db->db_disconnect($dbh);
430
431 if ($async) {
432 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
433 print qq~{"ok":true,"message_id":$new_msg_id,"thread_id":$tid}~;
434 } else {
435 my $back = $is_admin ? "/admin_messages.cgi" : "/support.cgi";
436 print "Status: 302 Found\nLocation: $back?thread_id=$tid\n\n";
437 }
438}
439
440#======================================================================
441# support_upload.cgi -- multipart POST attachment upload
442#======================================================================
443sub _sup_upload {
444 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
445
446 my $upload_done = 0;
447 my $fail = sub {
448 my ($msg) = @_;
449 $msg =~ s/"/\\"/g;
450 print qq~{"success":0,"error":"$msg"}~;
451 $upload_done = 1;
452 exit;
453 };
454
455 if (my $cgi_err = $q->cgi_error) {
456 $fail->("upload rejected: $cgi_err");
457 }
458
459 my $userinfo = $auth->login_verify();
460 $fail->('not authenticated') unless $userinfo && $userinfo->{user_id};
461 my $uid = $userinfo->{user_id};
462 $uid =~ s/[^0-9]//g;
463 $fail->('bad user id') unless $uid;
464
465 my $uploaded = $q->upload('image');
466 $fail->('no file received') unless $uploaded;
467
468 my $tmpname = $q->tmpFileName($uploaded);
469 $fail->('temp file missing') unless $tmpname && -f $tmpname;
470
471 my $size = -s $tmpname;
472 $fail->('empty file') unless $size && $size > 0;
473 $fail->('file too large (max 10MB)') if $size > 10 * 1024 * 1024;
474
475 open(my $fh, '<', $tmpname) or $fail->('cannot read temp');
476 binmode $fh;
477 read($fh, my $head, 16);
478 close $fh;
479
480 my ($ext, $mime);
481 if ($head =~ /^\xFF\xD8\xFF/) { $ext = 'jpg'; $mime = 'image/jpeg'; }
482 elsif ($head =~ /^\x89PNG\r\n\x1A\n/) { $ext = 'png'; $mime = 'image/png'; }
483 elsif ($head =~ /^GIF8[79]a/) { $ext = 'gif'; $mime = 'image/gif'; }
484 elsif ($head =~ /^RIFF.{4}WEBP/s) { $ext = 'webp'; $mime = 'image/webp'; }
485 elsif ($head =~ /^BM/) { $ext = 'bmp'; $mime = 'image/bmp'; }
486 else { $fail->('not a recognized image (JPG/PNG/GIF/WebP/BMP)'); }
487
488 my @hex = ('0'..'9', 'a'..'f');
489 my $id = '';
490 $id .= $hex[int(rand(@hex))] for 1..16;
491 my $filename = "$id.$ext";
492
493 my $base_dir = '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/uploads';
494 my $sup_dir = "$base_dir/support";
495 my $dest_path = "$sup_dir/$filename";
496
497 unless (-d $base_dir) { mkdir $base_dir, 0755 or $fail->("mkdir base: $!"); }
498 unless (-d $sup_dir) { mkdir $sup_dir, 0755 or $fail->("mkdir support: $!"); }
499
500 open(my $in_fh, '<', $tmpname) or $fail->("open temp: $!");
501 open(my $out_fh, '>', $dest_path) or $fail->("open dest: $!");
502 binmode $in_fh;
503 binmode $out_fh;
504 while (read($in_fh, my $buf, 8192)) { print $out_fh $buf; }
505 close $in_fh;
506 close $out_fh;
507
508 my $dbh = $db->db_connect();
509 $db->db_readwrite($dbh, qq~
510 INSERT INTO `${DB}`.support_attachments SET
511 id = '$id',
512 user_id = '$uid',
513 filename = '$filename',
514 mime_type = '$mime',
515 size_bytes = '$size'
516 ~, $ENV{SCRIPT_NAME}, __LINE__);
517 $db->db_disconnect($dbh);
518
519 my $size_kb = int($size / 1024);
520 print qq~{"success":1,"id":"$id","url":"/support_file.cgi?id=$id","filename":"$filename","size_kb":$size_kb,"mime":"$mime"}~;
521}
522
523#======================================================================
524# support_file.cgi -- stream an attachment's bytes
525#======================================================================
526sub _sup_file {
527 my $not_found = sub {
528 print "Status: 404 Not Found\nContent-Type: text/plain\n\nnot found\n";
529 exit;
530 };
531 my $forbidden = sub {
532 print "Status: 403 Forbidden\nContent-Type: text/plain\n\nforbidden\n";
533 exit;
534 };
535
536 my $id = $q->param('id') || '';
537 $id =~ s/[^a-zA-Z0-9]//g;
538 $not_found->() unless length($id) == 16;
539
540 my $userinfo = $auth->login_verify();
541 $forbidden->() unless $userinfo && $userinfo->{user_id};
542 my $viewer_uid = $userinfo->{user_id};
543 $viewer_uid =~ s/[^0-9]//g;
544 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
545
546 my $dbh = $db->db_connect();
547 my $row = $db->db_readwrite($dbh, qq~
548 SELECT a.user_id AS uploader_id, a.thread_id, a.filename, a.mime_type, a.size_bytes,
549 t.customer_user_id
550 FROM `${DB}`.support_attachments a
551 LEFT JOIN `${DB}`.support_threads t ON t.id = a.thread_id
552 WHERE a.id='$id' LIMIT 1
553 ~, $ENV{SCRIPT_NAME}, __LINE__);
554 $db->db_disconnect($dbh);
555
556 $not_found->() unless $row && $row->{filename};
557
558 my $allowed = 0;
559 $allowed = 1 if $is_admin;
560 $allowed = 1 if $row->{uploader_id} eq $viewer_uid;
561 $allowed = 1 if $row->{customer_user_id} && $row->{customer_user_id} eq $viewer_uid;
562 $forbidden->() unless $allowed;
563
564 my $filename = $row->{filename};
565 $not_found->() if $filename =~ m{[/\\]} || $filename =~ /\.\./;
566
567 my $path = "/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/uploads/support/$filename";
568 $not_found->() unless -r $path;
569
570 my %ok_mime = map { $_ => 1 } qw(image/jpeg image/png image/gif image/webp image/bmp);
571 my $mime = $row->{mime_type} || 'application/octet-stream';
572 $mime = 'application/octet-stream' unless $ok_mime{$mime};
573
574 my $size = -s $path;
575 print "Content-Type: $mime\n";
576 print "Content-Length: $size\n";
577 print "Cache-Control: private, max-age=31536000, immutable\n";
578 print "X-Content-Type-Options: nosniff\n";
579 print "\n";
580
581 binmode STDOUT;
582 open(my $fh, '<', $path) or $not_found->();
583 binmode $fh;
584 my $buf;
585 while (read($fh, $buf, 65536)) { print STDOUT $buf; }
586 close $fh;
587}
588
589#======================================================================
590# helpers
591#======================================================================
592sub _sup_h {
593 my $s = shift; $s = '' unless defined $s;
594 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
595 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
596 return $s;
597}
598
599sub _sup_body_html {
600 my $s = shift; $s = '' unless defined $s;
601 $s = _sup_h($s);
602 $s =~ s/\$/\\\$/g;
603 $s =~ s/\@/\\\@/g;
604 $s =~ s/\r//g;
605 $s =~ s/\n/<br>/g;
606 return $s;
607}
608
609sub _sup_status_label {
610 my $s = shift || '';
611 return 'Open' if $s eq 'open';
612 return 'Awaiting reply' if $s eq 'waiting_admin';
613 return 'Waiting on you' if $s eq 'waiting_customer';
614 return 'Resolved' if $s eq 'resolved';
615 return 'Closed' if $s eq 'closed';
616 return $s;
617}
618sub _sup_status_class {
619 my $s = shift || '';
620 return 'st-open' if $s eq 'open' || $s eq 'waiting_admin';
621 return 'st-waiting' if $s eq 'waiting_customer';
622 return 'st-resolved' if $s eq 'resolved';
623 return 'st-closed' if $s eq 'closed';
624 return 'st-open';
625}
626
627sub _sup_humanize_ts {
628 my $t = shift; $t = '' unless defined $t;
629 return '' unless $t =~ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/;
630 my ($y, $mo, $d, $hh, $mm, $ss) = ($1, $2, $3, $4, $5, $6);
631 require Time::Local;
632 my $then = eval { Time::Local::timelocal($ss, $mm, $hh, $d, $mo - 1, $y - 1900) } || 0;
633 my $diff = time - $then;
634 my $label;
635 if ($diff < 60) { $label = 'Just now'; }
636 elsif ($diff < 3600) { $label = int($diff/60) . 'm ago'; }
637 elsif ($diff < 86400) { $label = int($diff/3600) . 'h ago'; }
638 elsif ($diff < 172800) { $label = 'Yesterday'; }
639 elsif ($diff < 604800) { $label = int($diff/86400) . 'd ago'; }
640 else {
641 my @mn = ('','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec');
642 $label = "$mn[int($mo)] $d";
643 }
644 return $label unless $then > 0;
645 return qq~<span class="ts" data-ts="$then" data-fmt="relative">$label</span>~;
646}
647
648sub _sup_json_str {
649 my $s = shift; $s = '' unless defined $s;
650 $s =~ s/\\/\\\\/g;
651 $s =~ s/"/\\"/g;
652 $s =~ s/\r//g;
653 $s =~ s/\n/\\n/g;
654 $s =~ s/\t/\\t/g;
655 $s =~ s/[\x00-\x1f]//g;
656 return $s;
657}
658sub _sup_json_obj {
659 my $h = shift;
660 my @kv;
661 foreach my $k (sort keys %$h) {
662 my $v = $h->{$k};
663 if (defined $v && $v =~ /^-?\d+$/) {
664 push @kv, qq~"$k":$v~;
665 } else {
666 push @kv, qq~"$k":"~ . _sup_json_str($v) . qq~"~;
667 }
668 }
669 return '{' . join(',', @kv) . '}';
670}