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

O Operator
Diff

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

added on local at 2026-07-11 18:31:51

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