Diff -- /var/www/vhosts/webstls.com/httpdocs/support.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/support.cgi

added on WebSTLs (webstls.com) at 2026-07-01 22:26:48

Added
+809
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to af0e3fefb98c
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# WebSTLs -- customer support center + AJAX/attachment endpoints.
4#
5# Consolidates the former support quintet via SCRIPT_NAME dispatch:
6# support.cgi -- two-pane list/thread UI + create_thread POST
7# support_file.cgi -- serves attachment bytes (image proxy)
8# support_poll.cgi -- JSON poll for new messages in a thread
9# support_send.cgi -- POST handler for sending a message reply
10# support_upload.cgi -- multipart image upload -> support_attachments
11#
12# Wrappers `do` this file; SCRIPT_NAME still reads as e.g.
13# /support_poll.cgi so dispatch below routes correctly. Helper subs are
14# prefixed with _sup_ to avoid name collisions.
15#
16# NOTE: the live-chat feature was removed -- async messaging only.
17# DB columns for chat state stay in place so a future re-add doesn't
18# need a schema migration; they're just never written anymore.
19#======================================================================
20
21# ---- Early die handler for JSON endpoints (poll) -------------------
22# Catch ANY failure before headers and turn it into a valid JSON 500
23# so the JS client (and the error log reader) can see the real cause.
24# Without this, a die() in any module load prints to STDERR and
25# Apache reports the unhelpful "End of script output before headers."
26our $__sp_started = 0;
27sub __sp_emit_error {
28 my ($where, $err) = @_;
29 chomp $err;
30 $err =~ s/\\/\\\\/g;
31 $err =~ s/"/\\"/g;
32 $err =~ s/[\r\n]+/ | /g;
33 unless ($__sp_started) {
34 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";
35 $__sp_started = 1;
36 }
37 print qq~{"ok":false,"error":"$where: $err"}~;
38 exit;
39}
40BEGIN {
41 # Only wire the JSON die trap for the poll endpoint. Page renders
42 # and multipart uploads have their own error paths and shouldn't
43 # short-circuit to a JSON 500.
44 if ((($ENV{SCRIPT_NAME} || '') =~ m{/support_poll\.cgi$})) {
45 $SIG{__DIE__} = sub {
46 die @_ if $^S; # inside an eval -- let it propagate
47 __sp_emit_error('die', $_[0]);
48 };
49 }
50}
51
52use strict;
53use warnings;
54
55use lib '/var/www/vhosts/webstls.com/httpdocs';
56use CGI;
57# support_upload.cgi historically bumped POST_MAX to 12MB for image
58# uploads. Set it unconditionally here -- it only affects multipart
59# parsing and the other branches have small forms.
60$CGI::POST_MAX = 12 * 1024 * 1024;
61
62use MODS::DBConnect;
63use MODS::Login;
64use MODS::WebSTLs::Config;
65
66$| = 1;
67
68my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'support';
69
70# ---- Dispatch ------------------------------------------------------
71if ($entry eq 'support_file') { _sup_file(); exit; }
72elsif ($entry eq 'support_poll') { _sup_poll(); exit; }
73elsif ($entry eq 'support_send') { _sup_send(); exit; }
74elsif ($entry eq 'support_upload') { _sup_upload(); exit; }
75else { _sup_main(); exit; }
76
77#======================================================================
78# support.cgi -- two-pane list/thread UI
79#======================================================================
80sub _sup_main {
81 require MODS::Template;
82 require MODS::WebSTLs::Wrapper;
83 require MODS::WebSTLs::Support;
84
85 my $q = CGI->new;
86 my $form = $q->Vars;
87 my $tfile = MODS::Template->new;
88 my $db = MODS::DBConnect->new;
89 my $auth = MODS::Login->new;
90 my $config = MODS::WebSTLs::Config->new;
91 my $wrap = MODS::WebSTLs::Wrapper->new;
92 my $sup = MODS::WebSTLs::Support->new;
93 my $DB = $config->settings('database_name');
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 };
189 }
190
191 my @message_rows;
192 if ($active_thread) {
193 # Pull every attachment row for this thread in one query so we can
194 # zip them into per-message lists without N+1 lookups.
195 my $atts_by_msg = {};
196 my $dbh_a = $db->db_connect();
197 if ($dbh_a) {
198 my @rows = $db->db_readwrite_multiple($dbh_a, qq~
199 SELECT id, message_id, filename, mime_type, size_bytes
200 FROM `${DB}`.support_attachments
201 WHERE thread_id='$active_tid'
202 ORDER BY uploaded_at ASC
203 ~, $ENV{SCRIPT_NAME}, __LINE__);
204 $db->db_disconnect($dbh_a);
205 for my $a (@rows) {
206 push @{ $atts_by_msg->{ $a->{message_id} } }, {
207 id => $a->{id},
208 url => "/support_file.cgi?id=$a->{id}",
209 name => $a->{filename},
210 };
211 }
212 }
213 foreach my $m (@$messages) {
214 my $role = $m->{sender_role};
215 my $atts = $atts_by_msg->{ $m->{id} } || [];
216 push @message_rows, {
217 id => $m->{id},
218 body_html => _sup_body_html($m->{body}),
219 ts => _sup_humanize_ts($m->{created_at}),
220 ts_iso => $m->{created_at},
221 sender_name => _sup_h($m->{sender_name} || ($role eq 'system' ? 'WebSTLs' : 'Anonymous')),
222 sender_initial => uc(substr($m->{sender_name} || ($role eq 'admin' ? 'W' : 'C'), 0, 1)),
223 is_customer => ($role eq 'customer') ? 1 : 0,
224 is_admin => ($role eq 'admin') ? 1 : 0,
225 is_system => ($role eq 'system') ? 1 : 0,
226 attachments => $atts,
227 has_attach => scalar(@$atts) ? 1 : 0,
228 };
229 }
230 }
231
232 my $tvars = {
233 threads => \@thread_tiles,
234 has_threads => scalar(@thread_tiles) ? 1 : 0,
235 thread_count => scalar(@thread_tiles),
236
237 has_active => $active_thread ? 1 : 0,
238 active_id => $active_thread ? $active_thread->{id} : 0,
239 active_subject => $active_thread ? _sup_h($active_thread->{subject}) : '',
240 active_status => $active_thread ? _sup_status_label($active_thread->{status}) : '',
241 active_status_class => $active_thread ? _sup_status_class($active_thread->{status}) : '',
242 messages => \@message_rows,
243 has_messages => scalar(@message_rows) ? 1 : 0,
244 last_message_id => scalar(@message_rows) ? $message_rows[-1]->{id} : 0,
245
246 show_new_form => $form->{new} ? 1 : 0,
247 new_err => $form->{err} || '',
248 has_new_err => ($form->{err}) ? 1 : 0,
249 just_opened => (($form->{ok} || '') eq 'opened') ? 1 : 0,
250 };
251
252 my $body = join('', $tfile->template('webstls_support.html', $tvars, $userinfo));
253
254 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";
255
256 $wrap->render({
257 userinfo => $userinfo,
258 page_key => 'support',
259 title => 'Support',
260 body => $body,
261 });
262 return;
263}
264
265#======================================================================
266# support_file.cgi -- serve attachment bytes.
267#
268# GET /support_file.cgi?id=<16-char token>
269# Streams the underlying bytes for a support_attachments row.
270#
271# Authz: a user can view an attachment when ANY of these are true:
272# - they are the uploader
273# - they own the thread (customer)
274# - they are an admin
275# Unlinked attachments (no thread_id yet) are visible to the uploader
276# only so the compose-time preview works.
277#======================================================================
278sub _sup_file {
279 my $q = CGI->new;
280 my $db = MODS::DBConnect->new;
281 my $auth = MODS::Login->new;
282 my $config = MODS::WebSTLs::Config->new;
283 my $DB = $config->settings('database_name');
284
285 my $id = $q->param('id') || '';
286 $id =~ s/[^a-zA-Z0-9]//g;
287 return _sup_file_not_found() unless length($id) == 16;
288
289 my $userinfo = $auth->login_verify();
290 return _sup_file_forbidden() unless $userinfo && $userinfo->{user_id};
291 my $viewer_uid = $userinfo->{user_id};
292 $viewer_uid =~ s/[^0-9]//g;
293 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
294
295 my $dbh = $db->db_connect();
296 my $row = $db->db_readwrite($dbh, qq~
297 SELECT a.user_id AS uploader_id, a.thread_id, a.filename, a.mime_type, a.size_bytes,
298 t.customer_user_id
299 FROM `${DB}`.support_attachments a
300 LEFT JOIN `${DB}`.support_threads t ON t.id = a.thread_id
301 WHERE a.id='$id' LIMIT 1
302 ~, $ENV{SCRIPT_NAME}, __LINE__);
303 $db->db_disconnect($dbh);
304
305 return _sup_file_not_found() unless $row && $row->{filename};
306
307 my $allowed = 0;
308 $allowed = 1 if $is_admin;
309 $allowed = 1 if $row->{uploader_id} eq $viewer_uid;
310 $allowed = 1 if $row->{customer_user_id} && $row->{customer_user_id} eq $viewer_uid;
311 return _sup_file_forbidden() unless $allowed;
312
313 my $filename = $row->{filename};
314 return _sup_file_not_found() if $filename =~ m{[/\\]} || $filename =~ /\.\./;
315
316 my $path = "/var/www/vhosts/webstls.com/httpdocs/uploads/support/$filename";
317 return _sup_file_not_found() unless -r $path;
318
319 my %ok_mime = map { $_ => 1 } qw(image/jpeg image/png image/gif image/webp image/bmp);
320 my $mime = $row->{mime_type} || 'application/octet-stream';
321 $mime = 'application/octet-stream' unless $ok_mime{$mime};
322
323 my $size = -s $path;
324 print "Content-Type: $mime\n";
325 print "Content-Length: $size\n";
326 print "Cache-Control: private, max-age=31536000, immutable\n";
327 print "X-Content-Type-Options: nosniff\n";
328 print "\n";
329
330 binmode STDOUT;
331 open(my $fh, '<', $path) or return _sup_file_not_found();
332 binmode $fh;
333 my $buf;
334 while (read($fh, $buf, 65536)) { print STDOUT $buf; }
335 close $fh;
336 return;
337}
338
339sub _sup_file_not_found {
340 print "Status: 404 Not Found\nContent-Type: text/plain\n\nnot found\n";
341 return;
342}
343sub _sup_file_forbidden {
344 print "Status: 403 Forbidden\nContent-Type: text/plain\n\nforbidden\n";
345 return;
346}
347
348#======================================================================
349# support_poll.cgi -- JSON poll for new messages in a thread.
350#
351# GET /support_poll.cgi?thread_id=N&since=ID
352# Returns JSON with every support_messages row in thread N whose id
353# is greater than ID. Used by the async ticket view to refresh the
354# conversation without a full reload.
355#
356# Response shape:
357# { ok: true, thread_id: N, last_id: M, status: "...",
358# messages: [ { id, sender_role, sender_name, body_html, ts }, ... ] }
359#
360# Customer side: only own threads. Admin side: any thread.
361#
362# Side effect: viewing the thread via the poll zeros the appropriate
363# side's unread counter, so the sidebar badge updates without a
364# refresh.
365#======================================================================
366sub _sup_poll {
367 require MODS::WebSTLs::Support;
368
369 my $q = CGI->new;
370 my $form = $q->Vars;
371 my $db = MODS::DBConnect->new;
372 my $auth = MODS::Login->new;
373 my $config = MODS::WebSTLs::Config->new;
374 my $sup = MODS::WebSTLs::Support->new;
375 my $DB = $config->settings('database_name');
376
377 my $userinfo = $auth->login_verify();
378 return _sup_poll_fail(401, 'not authenticated') unless $userinfo && $userinfo->{user_id};
379
380 my $uid = $userinfo->{user_id};
381 $uid =~ s/[^0-9]//g;
382 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
383
384 my $tid = $form->{thread_id} || 0; $tid =~ s/[^0-9]//g;
385 return _sup_poll_fail(400, 'missing thread_id') unless $tid;
386 my $since = $form->{since} || 0; $since =~ s/[^0-9]//g;
387
388 my $dbh = $db->db_connect();
389 my $thread = $sup->load_thread($db, $dbh, $DB, $tid);
390 unless ($thread && $thread->{id}) {
391 $db->db_disconnect($dbh);
392 return _sup_poll_fail(404, 'thread not found');
393 }
394 unless ($is_admin || $thread->{customer_user_id} eq $uid) {
395 $db->db_disconnect($dbh);
396 return _sup_poll_fail(403, 'not your thread');
397 }
398
399 my $rows = $sup->load_messages($db, $dbh, $DB, $tid, $since);
400
401 # Pull attachments for any newly-returned messages so the poll can
402 # render inline thumbnails. Guarded against the table not existing.
403 my %atts_by_msg;
404 if (@$rows) {
405 my @msg_ids = map { $_->{id} } @$rows;
406 my $have_at = $db->db_readwrite($dbh, qq~
407 SELECT COUNT(*) AS n FROM information_schema.tables
408 WHERE table_schema='$DB' AND table_name='support_attachments'
409 ~, $ENV{SCRIPT_NAME}, __LINE__);
410 if ($have_at && $have_at->{n}) {
411 my $in_list = join(',', map { "'$_'" } @msg_ids);
412 my @arows = $db->db_readwrite_multiple($dbh, qq~
413 SELECT id, message_id, filename
414 FROM `${DB}`.support_attachments
415 WHERE message_id IN ($in_list)
416 ORDER BY uploaded_at ASC
417 ~, $ENV{SCRIPT_NAME}, __LINE__);
418 for my $a (@arows) {
419 push @{ $atts_by_msg{ $a->{message_id} } }, {
420 id => $a->{id},
421 url => "/support_file.cgi?id=$a->{id}",
422 name => $a->{filename},
423 };
424 }
425 }
426 }
427
428 # Mark the user's side read while we have the connection open. The
429 # poll counts as "user is looking" so it's the right moment to clear
430 # their badge.
431 my $side = $is_admin ? 'admin' : 'customer';
432 $sup->mark_read($db, $dbh, $DB, $tid, $side);
433
434 # Re-load the thread row for the latest status.
435 my $fresh = $sup->load_thread($db, $dbh, $DB, $tid);
436 $db->db_disconnect($dbh);
437
438 # ---- Build JSON ----
439 my $last_id = 0;
440 my @msg_json;
441 foreach my $m (@$rows) {
442 $last_id = $m->{id} if $m->{id} > $last_id;
443 my $body = $m->{body};
444 # HTML-escape and add <br> for newlines. Skip $/\@ escapes here --
445 # this is going into a JSON string then into innerHTML, not into
446 # the template eval pipeline.
447 $body =~ s/&/&amp;/g; $body =~ s/</&lt;/g; $body =~ s/>/&gt;/g;
448 $body =~ s/"/&quot;/g; $body =~ s/'/&#39;/g;
449 $body =~ s/\r//g; $body =~ s/\n/<br>/g;
450
451 my $name = $m->{sender_name} || ($m->{sender_role} eq 'system' ? 'WebSTLs' : 'User');
452 # Inline-render attachments as part of body_html so the existing
453 # client appendMessage() (which sets innerHTML) picks them up
454 # without any extra JS surface.
455 my $atts = $atts_by_msg{ $m->{id} } || [];
456 if (@$atts) {
457 $body .= '<div class="sup-msg-atts">';
458 for my $a (@$atts) {
459 my $aid = $a->{id};
460 $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>~;
461 }
462 $body .= '</div>';
463 }
464 push @msg_json, _sup_json_obj({
465 id => $m->{id} + 0,
466 sender_role => $m->{sender_role},
467 sender_name => $name,
468 body_html => $body,
469 ts => $m->{created_at} || '',
470 });
471 }
472 $last_id = $since if $last_id < $since;
473
474 my $thread_status = $fresh ? ($fresh->{status} || '') : '';
475
476 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
477 print "{\"ok\":true,";
478 print "\"thread_id\":$tid,";
479 print "\"last_id\":$last_id,";
480 print "\"status\":\"" . _sup_json_str($thread_status) . "\",";
481 print "\"messages\":[" . join(',', @msg_json) . "]";
482 print "}";
483 return;
484}
485
486sub _sup_poll_fail {
487 my ($code, $msg) = @_;
488 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";
489 $msg =~ s/"/\\"/g;
490 print qq~{"ok":false,"error":"$msg"}~;
491 return;
492}
493
494#======================================================================
495# support_send.cgi -- POST handler for sending a support message.
496#
497# POST /support_send.cgi
498# thread_id=N
499# body=<text>
500# (optional) async=1 -- return JSON instead of redirecting; used
501# by the async panel so the page never
502# reloads while you're typing
503#
504# The caller's role is derived from their login session:
505# - users.is_admin=1 -> sender_role='admin'
506# - otherwise -> sender_role='customer' (must own the thread)
507#
508# Customer sending into a thread they don't own -> 403. Admin can post
509# into any thread on the platform.
510#======================================================================
511sub _sup_send {
512 require MODS::WebSTLs::Support;
513
514 my $q = CGI->new;
515 my $form = $q->Vars;
516 my $db = MODS::DBConnect->new;
517 my $auth = MODS::Login->new;
518 my $config = MODS::WebSTLs::Config->new;
519 my $sup = MODS::WebSTLs::Support->new;
520 my $DB = $config->settings('database_name');
521
522 my $async = $form->{async} ? 1 : 0;
523
524 my $userinfo = $auth->login_verify();
525 return _sup_send_fail($async, 401, 'not authenticated') unless $userinfo && $userinfo->{user_id};
526
527 my $uid = $userinfo->{user_id};
528 $uid =~ s/[^0-9]//g;
529 my $is_admin = $userinfo->{is_admin} ? 1 : 0;
530
531 my $tid = $form->{thread_id} || 0;
532 $tid =~ s/[^0-9]//g;
533 return _sup_send_fail($async, 400, 'missing thread_id') unless $tid;
534
535 my $body = $form->{body} || '';
536 $body =~ s/^\s+|\s+$//g;
537 return _sup_send_fail($async, 400, 'empty message') unless length $body;
538 $body = substr($body, 0, 50000);
539
540 my $dbh = $db->db_connect();
541
542 # Ownership check.
543 my $thread = $sup->load_thread($db, $dbh, $DB, $tid);
544 unless ($thread && $thread->{id}) {
545 $db->db_disconnect($dbh);
546 return _sup_send_fail($async, 404, 'thread not found');
547 }
548
549 my $sender_role;
550 if ($is_admin) {
551 # Admins can post into any thread.
552 $sender_role = 'admin';
553 } else {
554 if ($thread->{customer_user_id} ne $uid) {
555 $db->db_disconnect($dbh);
556 return _sup_send_fail($async, 403, 'not your thread');
557 }
558 $sender_role = 'customer';
559 }
560
561 my $new_msg_id = $sup->send_message($db, $dbh, $DB, {
562 thread_id => $tid,
563 sender_role => $sender_role,
564 sender_user_id => $uid,
565 body => $body,
566 });
567
568 # Claim any in-flight attachments the sender uploaded to this reply.
569 # support_upload.cgi parked them with thread_id/message_id NULL; we
570 # stamp both columns now so the attachment renders inline below the
571 # message body and the support_file.cgi access check accepts it.
572 my @ids = grep { /^[a-zA-Z0-9]{16}$/ } $q->multi_param('attach_id');
573 if (@ids && $new_msg_id) {
574 my $in_list = join(',', map { "'$_'" } @ids);
575 $db->db_readwrite($dbh, qq~
576 UPDATE `${DB}`.support_attachments
577 SET thread_id='$tid', message_id='$new_msg_id'
578 WHERE id IN ($in_list)
579 AND user_id='$uid'
580 AND thread_id IS NULL
581 ~, $ENV{SCRIPT_NAME}, __LINE__);
582 }
583
584 # Sender reads their own message instantly -- zero their side's
585 # unread counter so the badge doesn't tick from their own send.
586 $sup->mark_read($db, $dbh, $DB, $tid, $sender_role);
587
588 $db->db_disconnect($dbh);
589
590 if ($async) {
591 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
592 print qq~{"ok":true,"message_id":$new_msg_id,"thread_id":$tid}~;
593 } else {
594 my $back = $is_admin ? "/admin_messages.cgi" : "/support.cgi";
595 print "Status: 302 Found\nLocation: $back?thread_id=$tid\n\n";
596 }
597 return;
598}
599
600sub _sup_send_fail {
601 my ($async, $code, $msg) = @_;
602 if ($async) {
603 print "Status: $code\nContent-Type: application/json\n\n";
604 $msg =~ s/"/\\"/g;
605 print qq~{"ok":false,"error":"$msg"}~;
606 } else {
607 print "Status: $code\nContent-Type: text/plain\n\n$msg\n";
608 }
609 return;
610}
611
612#======================================================================
613# support_upload.cgi -- multipart image upload -> support_attachments.
614#
615# Accepts multipart/form-data POST with field "image". On success:
616# 1. Validates auth (any logged-in user).
617# 2. Validates the file (JPG/PNG/GIF/WebP/BMP, max 10MB) by magic bytes.
618# 3. Generates a 16-char random id used in the public URL.
619# 4. Saves bytes to httpdocs/uploads/support/<id>.<ext>.
620# 5. INSERTs a support_attachments row with thread_id/message_id NULL.
621# 6. Returns JSON { success: 1, id, url, filename, size_kb, mime }.
622#
623# The attachment is unlinked (no thread_id/message_id) until the user
624# actually submits the message form -- support.cgi / support_send /
625# admin_messages.cgi each "claim" the in-flight IDs on submit by
626# UPDATEing them with the new thread_id + message_id.
627#======================================================================
628our $__sup_upload_done = 0;
629END {
630 # Fire only when we entered the upload branch and didn't finish
631 # cleanly. Guarded on $entry so a normal support.cgi render doesn't
632 # emit stray JSON at teardown.
633 my $ent = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : '';
634 if ($ent eq 'support_upload' && !$__sup_upload_done) {
635 print qq~{"success":0,"error":"server aborted before completing the upload"}~;
636 }
637}
638
639sub _sup_upload {
640 my $q = CGI->new;
641 my $db = MODS::DBConnect->new;
642 my $auth = MODS::Login->new;
643 my $config = MODS::WebSTLs::Config->new;
644 my $DB = $config->settings('database_name');
645
646 print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
647
648 if (my $cgi_err = $q->cgi_error) {
649 return _sup_upload_fail("upload rejected: $cgi_err");
650 }
651
652 my $userinfo = $auth->login_verify();
653 return _sup_upload_fail('not authenticated') unless $userinfo && $userinfo->{user_id};
654 my $uid = $userinfo->{user_id};
655 $uid =~ s/[^0-9]//g;
656 return _sup_upload_fail('bad user id') unless $uid;
657
658 my $uploaded = $q->upload('image');
659 return _sup_upload_fail('no file received') unless $uploaded;
660
661 my $tmpname = $q->tmpFileName($uploaded);
662 return _sup_upload_fail('temp file missing') unless $tmpname && -f $tmpname;
663
664 my $size = -s $tmpname;
665 return _sup_upload_fail('empty file') unless $size && $size > 0;
666 return _sup_upload_fail('file too large (max 10MB)') if $size > 10 * 1024 * 1024;
667
668 open(my $fh, '<', $tmpname) or return _sup_upload_fail('cannot read temp');
669 binmode $fh;
670 read($fh, my $head, 16);
671 close $fh;
672
673 my ($ext, $mime);
674 if ($head =~ /^\xFF\xD8\xFF/) { $ext = 'jpg'; $mime = 'image/jpeg'; }
675 elsif ($head =~ /^\x89PNG\r\n\x1A\n/) { $ext = 'png'; $mime = 'image/png'; }
676 elsif ($head =~ /^GIF8[79]a/) { $ext = 'gif'; $mime = 'image/gif'; }
677 elsif ($head =~ /^RIFF.{4}WEBP/s) { $ext = 'webp'; $mime = 'image/webp'; }
678 elsif ($head =~ /^BM/) { $ext = 'bmp'; $mime = 'image/bmp'; }
679 else { return _sup_upload_fail('not a recognized image (JPG/PNG/GIF/WebP/BMP)'); }
680
681 my @hex = ('0'..'9', 'a'..'f');
682 my $id = '';
683 $id .= $hex[int(rand(@hex))] for 1..16;
684 my $filename = "$id.$ext";
685
686 my $base_dir = '/var/www/vhosts/webstls.com/httpdocs/uploads';
687 my $sup_dir = "$base_dir/support";
688 my $dest_path = "$sup_dir/$filename";
689
690 unless (-d $base_dir) { mkdir $base_dir, 0755 or return _sup_upload_fail("mkdir base: $!"); }
691 unless (-d $sup_dir) { mkdir $sup_dir, 0755 or return _sup_upload_fail("mkdir support: $!"); }
692
693 open(my $in_fh, '<', $tmpname) or return _sup_upload_fail("open temp: $!");
694 open(my $out_fh, '>', $dest_path) or return _sup_upload_fail("open dest: $!");
695 binmode $in_fh;
696 binmode $out_fh;
697 while (read($in_fh, my $buf, 8192)) { print $out_fh $buf; }
698 close $in_fh;
699 close $out_fh;
700
701 my $dbh = $db->db_connect();
702 $db->db_readwrite($dbh, qq~
703 INSERT INTO `${DB}`.support_attachments SET
704 id = '$id',
705 user_id = '$uid',
706 filename = '$filename',
707 mime_type = '$mime',
708 size_bytes = '$size'
709 ~, $ENV{SCRIPT_NAME}, __LINE__);
710 $db->db_disconnect($dbh);
711
712 my $size_kb = int($size / 1024);
713 print qq~{"success":1,"id":"$id","url":"/support_file.cgi?id=$id","filename":"$filename","size_kb":$size_kb,"mime":"$mime"}~;
714 $__sup_upload_done = 1;
715 return;
716}
717
718sub _sup_upload_fail {
719 my ($msg) = @_;
720 $msg =~ s/"/\\"/g;
721 print qq~{"success":0,"error":"$msg"}~;
722 $__sup_upload_done = 1;
723 return;
724}
725
726#======================================================================
727# Shared helpers (support.cgi main render only)
728#======================================================================
729sub _sup_h {
730 my $s = shift; $s = '' unless defined $s;
731 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
732 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
733 return $s;
734}
735
736# Message body -> HTML. Escape, then turn newlines into <br>. Then
737# also escape $ and @ so user-typed prices ($24.99) don't crash the
738# template engine's qq~~ eval. Same trick we use in listing_details.
739sub _sup_body_html {
740 my $s = shift; $s = '' unless defined $s;
741 $s = _sup_h($s);
742 $s =~ s/\$/\\\$/g;
743 $s =~ s/\@/\\\@/g;
744 $s =~ s/\r//g;
745 $s =~ s/\n/<br>/g;
746 return $s;
747}
748
749sub _sup_status_label {
750 my $s = shift || '';
751 return 'Open' if $s eq 'open';
752 return 'Awaiting reply' if $s eq 'waiting_admin';
753 return 'Waiting on you' if $s eq 'waiting_customer';
754 return 'Resolved' if $s eq 'resolved';
755 return 'Closed' if $s eq 'closed';
756 return $s;
757}
758sub _sup_status_class {
759 my $s = shift || '';
760 return 'st-open' if $s eq 'open' || $s eq 'waiting_admin';
761 return 'st-waiting' if $s eq 'waiting_customer';
762 return 'st-resolved' if $s eq 'resolved';
763 return 'st-closed' if $s eq 'closed';
764 return 'st-open';
765}
766
767# Friendly timestamps: "Just now", "5m ago", "2h ago", "Yesterday",
768# "3d ago", otherwise a date like "May 16".
769sub _sup_humanize_ts {
770 my $t = shift; $t = '' unless defined $t;
771 return '' unless $t =~ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/;
772 my ($y, $mo, $d, $hh, $mm, $ss) = ($1, $2, $3, $4, $5, $6);
773 require Time::Local;
774 my $then = eval { Time::Local::timelocal($ss, $mm, $hh, $d, $mo - 1, $y - 1900) } || 0;
775 my $diff = time - $then;
776 return 'Just now' if $diff < 60;
777 return int($diff/60) . 'm ago' if $diff < 3600;
778 return int($diff/3600) . 'h ago' if $diff < 86400;
779 return 'Yesterday' if $diff < 172800;
780 return int($diff/86400) . 'd ago' if $diff < 604800;
781 my @mn = ('','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec');
782 return "$mn[int($mo)] $d";
783}
784
785# ---- Tiny JSON helpers (no dependency on JSON::PP) -------------------
786sub _sup_json_str {
787 my $s = shift; $s = '' unless defined $s;
788 $s =~ s/\\/\\\\/g;
789 $s =~ s/"/\\"/g;
790 $s =~ s/\r//g;
791 $s =~ s/\n/\\n/g;
792 $s =~ s/\t/\\t/g;
793 # Strip any remaining control chars to keep the JSON parser happy.
794 $s =~ s/[\x00-\x1f]//g;
795 return $s;
796}
797sub _sup_json_obj {
798 my $h = shift;
799 my @kv;
800 foreach my $k (sort keys %$h) {
801 my $v = $h->{$k};
802 if (defined $v && $v =~ /^-?\d+$/) {
803 push @kv, qq~"$k":$v~;
804 } else {
805 push @kv, qq~"$k":"~ . _sup_json_str($v) . qq~"~;
806 }
807 }
808 return '{' . join(',', @kv) . '}';
809}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help