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

O Operator
Diff

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

added on local at 2026-07-11 18:38:41

Added
+726
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to eab83857d01c
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# AffSoft -- customer support center + support-related endpoints.
4#
5# Consolidates support.cgi (main center), support_file.cgi (attachment
6# serve), support_poll.cgi (JSON AJAX poll), support_send.cgi (POST send
7# handler), and support_upload.cgi (JSON multipart upload) via
8# SCRIPT_NAME dispatch. Each of the four secondary entries is a
9# 3-line wrapper that `do`s this file.
10#
11# NOTE: support_poll.cgi installs an early $SIG{__DIE__} to convert
12# fatal errors into JSON 500 responses. That guard is installed at
13# top-level here too so the semantics survive the merge; it stays
14# harmless for the other entries (only fires on top-level die during
15# module load / initial execution).
16#======================================================================
17
18# ---- Early error trap for JSON entries (poll/upload) ----------------
19my $__sp_started = 0;
20sub __sp_emit_error {
21 my ($where, $err) = @_;
22 chomp $err;
23 $err =~ s/\\/\\\\/g;
24 $err =~ s/"/\\"/g;
25 $err =~ s/[\r\n]+/ | /g;
26 unless ($__sp_started) {
27 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";
28 $__sp_started = 1;
29 }
30 print qq~{"ok":false,"error":"$where: $err"}~;
31 exit;
32}
33BEGIN {
34 my $sn = $ENV{SCRIPT_NAME} || '';
35 # Only install the JSON-500 die trap for the poll entry; other
36 # entries use non-JSON responses and want the default die behaviour.
37 if ($sn =~ m{/support_poll\.cgi$}) {
38 $SIG{__DIE__} = sub {
39 die @_ if $^S; # inside an eval -- let it propagate
40 __sp_emit_error('die', $_[0]);
41 };
42 }
43}
44
45use strict;
46use warnings;
47
48use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com';
49use CGI;
50use MODS::DBConnect;
51use MODS::Login;
52use MODS::AffSoft::Config;
53
54my $__entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'support';
55
56# support_upload.cgi bumps POST_MAX before parsing multipart body.
57$CGI::POST_MAX = 12 * 1024 * 1024 if $__entry eq 'support_upload';
58
59# support_upload END block guard: prints a JSON error if we abort
60# before flipping $UPLOAD_DONE. Only meaningful for the upload entry.
61our $UPLOAD_DONE = 0;
62END {
63 if ($__entry eq 'support_upload' && !$UPLOAD_DONE) {
64 print qq~{"success":0,"error":"server aborted before completing the upload"}~;
65 }
66}
67
68$| = 1;
69
70my $q = CGI->new;
71my $form = $q->Vars;
72my $db = MODS::DBConnect->new;
73my $auth = MODS::Login->new;
74my $config = MODS::AffSoft::Config->new;
75my $DB = $config->settings('database_name');
76
77if ($__entry eq 'support_file') { _sup_file(); }
78elsif ($__entry eq 'support_poll') { _sup_poll(); }
79elsif ($__entry eq 'support_send') { _sup_send(); }
80elsif ($__entry eq 'support_upload') { _sup_upload(); }
81else { _sup_main(); }
82exit;
83
84#======================================================================
85# support.cgi entry: the two-pane support center.
86#======================================================================
87sub _sup_main {
88 require MODS::Template;
89 require MODS::AffSoft::Wrapper;
90 require MODS::AffSoft::Support;
91 my $tfile = MODS::Template->new;
92 my $wrap = MODS::AffSoft::Wrapper->new;
93 my $sup = MODS::AffSoft::Support->new;
94
95 my $userinfo = $auth->login_verify();
96 unless ($userinfo) {
97 print "Status: 302 Found\nLocation: /login.cgi?return=/support.cgi\n\n";
98 return;
99 }
100
101 my $uid = $userinfo->{user_id};
102 $uid =~ s/[^0-9]//g;
103
104 my $dbh = $db->db_connect();
105
106 # ---- POST handlers (PRG pattern) -----------------------------------
107 if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
108 my $action = $form->{action} || '';
109 if ($action eq 'create_thread') {
110 my $subject = $form->{subject} || '';
111 my $body = $form->{body} || '';
112 $subject =~ s/^\s+|\s+$//g; $body =~ s/^\s+|\s+$//g;
113 if (length $subject && length $body) {
114 my $tid = $sup->create_thread($db, $dbh, $DB, {
115 user_id => $uid, subject => $subject, body => $body,
116 });
117 if ($tid) {
118 # Claim any attachments the user uploaded during compose
119 # (support_upload.cgi inserted them with thread_id NULL).
120 # Linking them to the new thread + first message makes
121 # them visible inline below the message body and gates
122 # access to thread participants + admins.
123 my @ids = grep { /^[a-zA-Z0-9]{16}$/ } $q->multi_param('attach_id');
124 if (@ids) {
125 my $first_msg = $db->db_readwrite($dbh,
126 qq~SELECT id FROM `${DB}`.support_messages
127 WHERE thread_id='$tid' ORDER BY id ASC LIMIT 1~,
128 $ENV{SCRIPT_NAME}, __LINE__);
129 my $mid = ($first_msg && $first_msg->{id}) ? $first_msg->{id} : 0;
130 if ($mid) {
131 my $in_list = join(',', map { "'$_'" } @ids);
132 $db->db_readwrite($dbh, qq~
133 UPDATE `${DB}`.support_attachments
134 SET thread_id='$tid', message_id='$mid'
135 WHERE id IN ($in_list)
136 AND user_id='$uid'
137 AND thread_id IS NULL
138 ~, $ENV{SCRIPT_NAME}, __LINE__);
139 }
140 }
141 $db->db_disconnect($dbh);
142 print "Status: 302 Found\nLocation: /support.cgi?thread_id=$tid&ok=opened\n\n";
143 return;
144 }
145 }
146 $db->db_disconnect($dbh);
147 print "Status: 302 Found\nLocation: /support.cgi?new=1&err=missing\n\n";
148 return;
149 }
150 }
151
152 # ---- GET render ----------------------------------------------------
153 my $threads = $sup->list_threads_for_customer($db, $dbh, $DB, $uid);
154
155 # Active thread (if any)
156 my $active_tid = $form->{thread_id} || 0;
157 $active_tid =~ s/[^0-9]//g;
158 my $active_thread;
159 my $messages = [];
160 if ($active_tid) {
161 $active_thread = $sup->load_thread($db, $dbh, $DB, $active_tid);
162 if (!$active_thread || $active_thread->{customer_user_id} ne $uid) {
163 $active_thread = undef; # belongs to someone else -- hide it
164 $active_tid = 0;
165 } else {
166 $messages = $sup->load_messages($db, $dbh, $DB, $active_tid);
167 # Viewing the thread zeros the customer-side unread counter.
168 $sup->mark_read($db, $dbh, $DB, $active_tid, 'customer') if $active_thread->{customer_unread};
169 }
170 }
171
172 $db->db_disconnect($dbh);
173
174 # ---- Build template vars -------------------------------------------
175 my @thread_tiles;
176 foreach my $t (@$threads) {
177 my $is_active = ($active_tid && $t->{id} eq $active_tid) ? 1 : 0;
178 push @thread_tiles, {
179 id => $t->{id},
180 subject => _sup_h($t->{subject}),
181 status => $t->{status},
182 status_label => _sup_status_label($t->{status}),
183 status_class => _sup_status_class($t->{status}),
184 unread => $t->{customer_unread} || 0,
185 has_unread => ($t->{customer_unread} && !$is_active) ? 1 : 0,
186 is_active => $is_active,
187 last_at => _sup_humanize_ts($t->{last_message_at}),
188 last_at_epoch => _sup_ts_epoch($t->{last_message_at}),
189 };
190 }
191
192 my @message_rows;
193 if ($active_thread) {
194 # Pull every attachment row for this thread in one query so we can
195 # zip them into per-message lists without N+1 lookups.
196 my $atts_by_msg = {};
197 my $dbh_a = $db->db_connect();
198 if ($dbh_a) {
199 my @rows = $db->db_readwrite_multiple($dbh_a, qq~
200 SELECT id, message_id, filename, mime_type, size_bytes
201 FROM `${DB}`.support_attachments
202 WHERE thread_id='$active_tid'
203 ORDER BY uploaded_at ASC
204 ~, $ENV{SCRIPT_NAME}, __LINE__);
205 $db->db_disconnect($dbh_a);
206 for my $a (@rows) {
207 push @{ $atts_by_msg->{ $a->{message_id} } }, {
208 id => $a->{id},
209 url => "/support_file.cgi?id=$a->{id}",
210 name => $a->{filename},
211 };
212 }
213 }
214 foreach my $m (@$messages) {
215 my $role = $m->{sender_role};
216 my $atts = $atts_by_msg->{ $m->{id} } || [];
217 push @message_rows, {
218 id => $m->{id},
219 body_html => _sup_body_html($m->{body}),
220 ts => _sup_humanize_ts($m->{created_at}),
221 ts_iso => $m->{created_at},
222 ts_epoch => _sup_ts_epoch($m->{created_at}),
223 sender_name => _sup_h($m->{sender_name} || ($role eq 'system' ? 'WebSTLs' : 'Anonymous')),
224 sender_initial => uc(substr($m->{sender_name} || ($role eq 'admin' ? 'W' : 'C'), 0, 1)),
225 is_customer => ($role eq 'customer') ? 1 : 0,
226 is_admin => ($role eq 'admin') ? 1 : 0,
227 is_system => ($role eq 'system') ? 1 : 0,
228 attachments => $atts,
229 has_attach => scalar(@$atts) ? 1 : 0,
230 };
231 }
232 }
233
234 my $tvars = {
235 threads => \@thread_tiles,
236 has_threads => scalar(@thread_tiles) ? 1 : 0,
237 thread_count => scalar(@thread_tiles),
238
239 has_active => $active_thread ? 1 : 0,
240 active_id => $active_thread ? $active_thread->{id} : 0,
241 active_subject => $active_thread ? _sup_h($active_thread->{subject}) : '',
242 active_status => $active_thread ? _sup_status_label($active_thread->{status}) : '',
243 active_status_class => $active_thread ? _sup_status_class($active_thread->{status}) : '',
244 messages => \@message_rows,
245 has_messages => scalar(@message_rows) ? 1 : 0,
246 last_message_id => scalar(@message_rows) ? $message_rows[-1]->{id} : 0,
247
248 show_new_form => $form->{new} ? 1 : 0,
249 new_err => $form->{err} || '',
250 has_new_err => ($form->{err}) ? 1 : 0,
251 just_opened => (($form->{ok} || '') eq 'opened') ? 1 : 0,
252 };
253
254 my $body = join('', $tfile->template('webstls_support.html', $tvars, $userinfo));
255
256 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";
257
258 $wrap->render({
259 userinfo => $userinfo,
260 page_key => 'support',
261 title => 'Support',
262 body => $body,
263 });
264}
265
266#======================================================================
267# support_file.cgi entry: attachment stream
268#======================================================================
269sub _sup_file {
270 my $id = $q->param('id') || '';
271 $id =~ s/[^a-zA-Z0-9]//g;
272 _sup_file_not_found() unless length($id) == 16;
273
274 my $userinfo = $auth->login_verify();
275 _sup_file_forbidden() unless $userinfo && $userinfo->{user_id};
276 my $viewer_uid = $userinfo->{user_id};
277 $viewer_uid =~ s/[^0-9]//g;
278 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
279
280 my $dbh = $db->db_connect();
281 my $row = $db->db_readwrite($dbh, qq~
282 SELECT a.user_id AS uploader_id, a.thread_id, a.filename, a.mime_type, a.size_bytes,
283 t.customer_user_id
284 FROM `${DB}`.support_attachments a
285 LEFT JOIN `${DB}`.support_threads t ON t.id = a.thread_id
286 WHERE a.id='$id' LIMIT 1
287 ~, $ENV{SCRIPT_NAME}, __LINE__);
288 $db->db_disconnect($dbh);
289
290 _sup_file_not_found() unless $row && $row->{filename};
291
292 my $allowed = 0;
293 $allowed = 1 if $is_admin;
294 $allowed = 1 if $row->{uploader_id} eq $viewer_uid;
295 $allowed = 1 if $row->{customer_user_id} && $row->{customer_user_id} eq $viewer_uid;
296 _sup_file_forbidden() unless $allowed;
297
298 my $filename = $row->{filename};
299 _sup_file_not_found() if $filename =~ m{[/\\]} || $filename =~ /\.\./;
300
301 my $path = "/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/uploads/support/$filename";
302 _sup_file_not_found() unless -r $path;
303
304 my %ok_mime = map { $_ => 1 } qw(image/jpeg image/png image/gif image/webp image/bmp);
305 my $mime = $row->{mime_type} || 'application/octet-stream';
306 $mime = 'application/octet-stream' unless $ok_mime{$mime};
307
308 my $size = -s $path;
309 print "Content-Type: $mime\n";
310 print "Content-Length: $size\n";
311 print "Cache-Control: private, max-age=31536000, immutable\n";
312 print "X-Content-Type-Options: nosniff\n";
313 print "\n";
314
315 binmode STDOUT;
316 open(my $fh, '<', $path) or _sup_file_not_found();
317 binmode $fh;
318 my $buf;
319 while (read($fh, $buf, 65536)) { print STDOUT $buf; }
320 close $fh;
321}
322
323sub _sup_file_not_found {
324 print "Status: 404 Not Found\nContent-Type: text/plain\n\nnot found\n";
325 exit;
326}
327sub _sup_file_forbidden {
328 print "Status: 403 Forbidden\nContent-Type: text/plain\n\nforbidden\n";
329 exit;
330}
331
332#======================================================================
333# support_poll.cgi entry: AJAX JSON poll for new messages
334#======================================================================
335sub _sup_poll {
336 require MODS::AffSoft::Support;
337 my $sup = MODS::AffSoft::Support->new;
338
339 my $userinfo = $auth->login_verify();
340 _sup_poll_fail(401, 'not authenticated') unless $userinfo && $userinfo->{user_id};
341
342 my $uid = $userinfo->{user_id};
343 $uid =~ s/[^0-9]//g;
344 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
345
346 my $tid = $form->{thread_id} || 0; $tid =~ s/[^0-9]//g;
347 _sup_poll_fail(400, 'missing thread_id') unless $tid;
348 my $since = $form->{since} || 0; $since =~ s/[^0-9]//g;
349
350 my $dbh = $db->db_connect();
351 my $thread = $sup->load_thread($db, $dbh, $DB, $tid);
352 unless ($thread && $thread->{id}) {
353 $db->db_disconnect($dbh);
354 _sup_poll_fail(404, 'thread not found');
355 }
356 unless ($is_admin || $thread->{customer_user_id} eq $uid) {
357 $db->db_disconnect($dbh);
358 _sup_poll_fail(403, 'not your thread');
359 }
360
361 my $rows = $sup->load_messages($db, $dbh, $DB, $tid, $since);
362
363 # Pull attachments for any newly-returned messages so the poll can
364 # render inline thumbnails. Guarded against the table not existing.
365 my %atts_by_msg;
366 if (@$rows) {
367 my @msg_ids = map { $_->{id} } @$rows;
368 my $have_at = $db->db_readwrite($dbh, qq~
369 SELECT COUNT(*) AS n FROM information_schema.tables
370 WHERE table_schema='$DB' AND table_name='support_attachments'
371 ~, $ENV{SCRIPT_NAME}, __LINE__);
372 if ($have_at && $have_at->{n}) {
373 my $in_list = join(',', map { "'$_'" } @msg_ids);
374 my @arows = $db->db_readwrite_multiple($dbh, qq~
375 SELECT id, message_id, filename
376 FROM `${DB}`.support_attachments
377 WHERE message_id IN ($in_list)
378 ORDER BY uploaded_at ASC
379 ~, $ENV{SCRIPT_NAME}, __LINE__);
380 for my $a (@arows) {
381 push @{ $atts_by_msg{ $a->{message_id} } }, {
382 id => $a->{id},
383 url => "/support_file.cgi?id=$a->{id}",
384 name => $a->{filename},
385 };
386 }
387 }
388 }
389
390 # Mark the user's side read while we have the connection open. The
391 # poll counts as "user is looking" so it's the right moment to clear
392 # their badge.
393 my $side = $is_admin ? 'admin' : 'customer';
394 $sup->mark_read($db, $dbh, $DB, $tid, $side);
395
396 # Re-load the thread row for the latest chat status -- might have
397 # flipped between invited / active / ended while we were polling.
398 my $fresh = $sup->load_thread($db, $dbh, $DB, $tid);
399 $db->db_disconnect($dbh);
400
401 # ---- Build JSON ----
402 my $last_id = 0;
403 my @msg_json;
404 foreach my $m (@$rows) {
405 $last_id = $m->{id} if $m->{id} > $last_id;
406 my $body = $m->{body};
407 # HTML-escape and add <br> for newlines. Skip $/\@ escapes here --
408 # this is going into a JSON string then into innerHTML, not into
409 # the template eval pipeline.
410 $body =~ s/&/&amp;/g; $body =~ s/</&lt;/g; $body =~ s/>/&gt;/g;
411 $body =~ s/"/&quot;/g; $body =~ s/'/&#39;/g;
412 $body =~ s/\r//g; $body =~ s/\n/<br>/g;
413
414 my $name = $m->{sender_name} || ($m->{sender_role} eq 'system' ? 'WebSTLs' : 'User');
415 # Inline-render attachments as part of body_html so the existing
416 # client appendMessage() (which sets innerHTML) picks them up
417 # without any extra JS surface.
418 my $atts = $atts_by_msg{ $m->{id} } || [];
419 if (@$atts) {
420 $body .= '<div class="sup-msg-atts">';
421 for my $a (@$atts) {
422 my $aid = $a->{id};
423 $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>~;
424 }
425 $body .= '</div>';
426 }
427 push @msg_json, _sup_json_obj({
428 id => $m->{id} + 0,
429 sender_role => $m->{sender_role},
430 sender_name => $name,
431 body_html => $body,
432 ts => $m->{created_at} || '',
433 });
434 }
435 $last_id = $since if $last_id < $since;
436
437 my $thread_status = $fresh ? ($fresh->{status} || '') : '';
438
439 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
440 print "{\"ok\":true,";
441 print "\"thread_id\":$tid,";
442 print "\"last_id\":$last_id,";
443 print "\"status\":\"" . _sup_json_str($thread_status) . "\",";
444 print "\"messages\":[" . join(',', @msg_json) . "]";
445 print "}";
446}
447
448sub _sup_poll_fail {
449 my ($code, $msg) = @_;
450 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";
451 $msg =~ s/"/\\"/g;
452 print qq~{"ok":false,"error":"$msg"}~;
453 exit;
454}
455
456sub _sup_json_str {
457 my $s = shift; $s = '' unless defined $s;
458 $s =~ s/\\/\\\\/g;
459 $s =~ s/"/\\"/g;
460 $s =~ s/\r//g;
461 $s =~ s/\n/\\n/g;
462 $s =~ s/\t/\\t/g;
463 # Strip any remaining control chars to keep the JSON parser happy.
464 $s =~ s/[\x00-\x1f]//g;
465 return $s;
466}
467sub _sup_json_obj {
468 my $h = shift;
469 my @kv;
470 foreach my $k (sort keys %$h) {
471 my $v = $h->{$k};
472 if (defined $v && $v =~ /^-?\d+$/) {
473 push @kv, qq~"$k":$v~;
474 } else {
475 push @kv, qq~"$k":"~ . _sup_json_str($v) . qq~"~;
476 }
477 }
478 return '{' . join(',', @kv) . '}';
479}
480
481#======================================================================
482# support_send.cgi entry: POST body -> new support message
483#======================================================================
484sub _sup_send {
485 require MODS::AffSoft::Support;
486 my $sup = MODS::AffSoft::Support->new;
487
488 my $async = $form->{async} ? 1 : 0;
489
490 my $userinfo = $auth->login_verify();
491 _sup_send_fail($async, 401, 'not authenticated') unless $userinfo && $userinfo->{user_id};
492
493 my $uid = $userinfo->{user_id};
494 $uid =~ s/[^0-9]//g;
495 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
496
497 my $tid = $form->{thread_id} || 0;
498 $tid =~ s/[^0-9]//g;
499 _sup_send_fail($async, 400, 'missing thread_id') unless $tid;
500
501 my $body = $form->{body} || '';
502 $body =~ s/^\s+|\s+$//g;
503 _sup_send_fail($async, 400, 'empty message') unless length $body;
504 $body = substr($body, 0, 50000);
505
506 my $dbh = $db->db_connect();
507
508 # Ownership check.
509 my $thread = $sup->load_thread($db, $dbh, $DB, $tid);
510 unless ($thread && $thread->{id}) {
511 $db->db_disconnect($dbh);
512 _sup_send_fail($async, 404, 'thread not found');
513 }
514
515 my $sender_role;
516 if ($is_admin) {
517 # Admins can post into any thread.
518 $sender_role = 'admin';
519 } else {
520 if ($thread->{customer_user_id} ne $uid) {
521 $db->db_disconnect($dbh);
522 _sup_send_fail($async, 403, 'not your thread');
523 }
524 $sender_role = 'customer';
525 }
526
527 my $new_msg_id = $sup->send_message($db, $dbh, $DB, {
528 thread_id => $tid,
529 sender_role => $sender_role,
530 sender_user_id => $uid,
531 body => $body,
532 });
533
534 # Claim any in-flight attachments the sender uploaded to this reply.
535 # support_upload.cgi parked them with thread_id/message_id NULL; we
536 # stamp both columns now so the attachment renders inline below the
537 # message body and the support_file.cgi access check accepts it.
538 my @ids = grep { /^[a-zA-Z0-9]{16}$/ } $q->multi_param('attach_id');
539 if (@ids && $new_msg_id) {
540 my $in_list = join(',', map { "'$_'" } @ids);
541 $db->db_readwrite($dbh, qq~
542 UPDATE `${DB}`.support_attachments
543 SET thread_id='$tid', message_id='$new_msg_id'
544 WHERE id IN ($in_list)
545 AND user_id='$uid'
546 AND thread_id IS NULL
547 ~, $ENV{SCRIPT_NAME}, __LINE__);
548 }
549
550 # Sender reads their own message instantly -- zero their side's
551 # unread counter so the badge doesn't tick from their own send.
552 $sup->mark_read($db, $dbh, $DB, $tid, $sender_role);
553
554 $db->db_disconnect($dbh);
555
556 if ($async) {
557 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
558 print qq~{"ok":true,"message_id":$new_msg_id,"thread_id":$tid}~;
559 } else {
560 my $back = $is_admin ? "/admin_messages.cgi" : "/support.cgi";
561 print "Status: 302 Found\nLocation: $back?thread_id=$tid\n\n";
562 }
563}
564
565sub _sup_send_fail {
566 my ($async, $code, $msg) = @_;
567 if ($async) {
568 print "Status: $code\nContent-Type: application/json\n\n";
569 $msg =~ s/"/\\"/g;
570 print qq~{"ok":false,"error":"$msg"}~;
571 } else {
572 print "Status: $code\nContent-Type: text/plain\n\n$msg\n";
573 }
574 exit;
575}
576
577#======================================================================
578# support_upload.cgi entry: multipart image upload -> JSON response
579#======================================================================
580sub _sup_upload {
581 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
582
583 if (my $cgi_err = $q->cgi_error) {
584 _sup_upload_fail("upload rejected: $cgi_err");
585 }
586
587 my $userinfo = $auth->login_verify();
588 _sup_upload_fail('not authenticated') unless $userinfo && $userinfo->{user_id};
589 my $uid = $userinfo->{user_id};
590 $uid =~ s/[^0-9]//g;
591 _sup_upload_fail('bad user id') unless $uid;
592
593 my $uploaded = $q->upload('image');
594 _sup_upload_fail('no file received') unless $uploaded;
595
596 my $tmpname = $q->tmpFileName($uploaded);
597 _sup_upload_fail('temp file missing') unless $tmpname && -f $tmpname;
598
599 my $size = -s $tmpname;
600 _sup_upload_fail('empty file') unless $size && $size > 0;
601 _sup_upload_fail('file too large (max 10MB)') if $size > 10 * 1024 * 1024;
602
603 open(my $fh, '<', $tmpname) or _sup_upload_fail('cannot read temp');
604 binmode $fh;
605 read($fh, my $head, 16);
606 close $fh;
607
608 my ($ext, $mime);
609 if ($head =~ /^\xFF\xD8\xFF/) { $ext = 'jpg'; $mime = 'image/jpeg'; }
610 elsif ($head =~ /^\x89PNG\r\n\x1A\n/) { $ext = 'png'; $mime = 'image/png'; }
611 elsif ($head =~ /^GIF8[79]a/) { $ext = 'gif'; $mime = 'image/gif'; }
612 elsif ($head =~ /^RIFF.{4}WEBP/s) { $ext = 'webp'; $mime = 'image/webp'; }
613 elsif ($head =~ /^BM/) { $ext = 'bmp'; $mime = 'image/bmp'; }
614 else { _sup_upload_fail('not a recognized image (JPG/PNG/GIF/WebP/BMP)'); }
615
616 my @hex = ('0'..'9', 'a'..'f');
617 my $id = '';
618 $id .= $hex[int(rand(@hex))] for 1..16;
619 my $filename = "$id.$ext";
620
621 my $base_dir = '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/uploads';
622 my $sup_dir = "$base_dir/support";
623 my $dest_path = "$sup_dir/$filename";
624
625 unless (-d $base_dir) { mkdir $base_dir, 0755 or _sup_upload_fail("mkdir base: $!"); }
626 unless (-d $sup_dir) { mkdir $sup_dir, 0755 or _sup_upload_fail("mkdir support: $!"); }
627
628 open(my $in_fh, '<', $tmpname) or _sup_upload_fail("open temp: $!");
629 open(my $out_fh, '>', $dest_path) or _sup_upload_fail("open dest: $!");
630 binmode $in_fh;
631 binmode $out_fh;
632 while (read($in_fh, my $buf, 8192)) { print $out_fh $buf; }
633 close $in_fh;
634 close $out_fh;
635
636 my $dbh = $db->db_connect();
637 $db->db_readwrite($dbh, qq~
638 INSERT INTO `${DB}`.support_attachments SET
639 id = '$id',
640 user_id = '$uid',
641 filename = '$filename',
642 mime_type = '$mime',
643 size_bytes = '$size'
644 ~, $ENV{SCRIPT_NAME}, __LINE__);
645 $db->db_disconnect($dbh);
646
647 my $size_kb = int($size / 1024);
648 print qq~{"success":1,"id":"$id","url":"/support_file.cgi?id=$id","filename":"$filename","size_kb":$size_kb,"mime":"$mime"}~;
649 $UPLOAD_DONE = 1;
650}
651
652sub _sup_upload_fail {
653 my ($msg) = @_;
654 $msg =~ s/"/\\"/g;
655 print qq~{"success":0,"error":"$msg"}~;
656 $UPLOAD_DONE = 1;
657 exit;
658}
659
660#======================================================================
661# Shared helpers (support.cgi main entry uses these)
662#======================================================================
663sub _sup_h {
664 my $s = shift; $s = '' unless defined $s;
665 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
666 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
667 return $s;
668}
669
670# Message body -> HTML. Escape, then turn newlines into <br>. Then
671# also escape $ and @ so user-typed prices ($24.99) don't crash the
672# template engine's qq~~ eval. Same trick we use in listing_details.
673sub _sup_body_html {
674 my $s = shift; $s = '' unless defined $s;
675 $s = _sup_h($s);
676 $s =~ s/\$/\\\$/g;
677 $s =~ s/\@/\\\@/g;
678 $s =~ s/\r//g;
679 $s =~ s/\n/<br>/g;
680 return $s;
681}
682
683sub _sup_status_label {
684 my $s = shift || '';
685 return 'Open' if $s eq 'open';
686 return 'Awaiting reply' if $s eq 'waiting_admin';
687 return 'Waiting on you' if $s eq 'waiting_customer';
688 return 'Resolved' if $s eq 'resolved';
689 return 'Closed' if $s eq 'closed';
690 return $s;
691}
692sub _sup_status_class {
693 my $s = shift || '';
694 return 'st-open' if $s eq 'open' || $s eq 'waiting_admin';
695 return 'st-waiting' if $s eq 'waiting_customer';
696 return 'st-resolved' if $s eq 'resolved';
697 return 'st-closed' if $s eq 'closed';
698 return 'st-open';
699}
700
701# Friendly timestamps: "Just now", "5m ago", "2h ago", "Yesterday",
702# "3d ago", otherwise a date like "May 16".
703sub _sup_humanize_ts {
704 my $t = shift; $t = '' unless defined $t;
705 return '' unless $t =~ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/;
706 my ($y, $mo, $d, $hh, $mm, $ss) = ($1, $2, $3, $4, $5, $6);
707 require Time::Local;
708 my $then = eval { Time::Local::timelocal($ss, $mm, $hh, $d, $mo - 1, $y - 1900) } || 0;
709 my $diff = time - $then;
710 return 'Just now' if $diff < 60;
711 return int($diff/60) . 'm ago' if $diff < 3600;
712 return int($diff/3600) . 'h ago' if $diff < 86400;
713 return 'Yesterday' if $diff < 172800;
714 return int($diff/86400) . 'd ago' if $diff < 604800;
715 my @mn = ('','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec');
716 return "$mn[int($mo)] $d";
717}
718# Epoch seconds for a MySQL DATETIME string; 0 on failure. Powers the
719# data-ts= viewer-local rewrite on the client.
720sub _sup_ts_epoch {
721 my $t = shift; return 0 unless defined $t && length $t;
722 return 0 unless $t =~ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/;
723 my ($y, $mo, $d, $hh, $mm, $ss) = ($1, $2, $3, $4, $5, $6);
724 require Time::Local;
725 return eval { Time::Local::timelocal($ss, $mm, $hh, $d, $mo - 1, $y - 1900) } || 0;
726}