--- /dev/null +++ b/app/Http/Middleware/PrivateSupportResponse.php @@ -0,0 +1,23 @@ +headers->set('Cache-Control', 'private, no-store, no-cache, must-revalidate, max-age=0'); + $response->headers->set('Pragma', 'no-cache'); + $response->headers->set('Expires', '0'); + $response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('Referrer-Policy', 'same-origin'); + + return $response; + } +} --- a/app/Http/Controllers/SupportTicketController.php +++ b/app/Http/Controllers/SupportTicketController.php @@ -6,123 +6,324 @@ use App\Models\SupportTicket; use App\Models\SupportTicketAttachment; use App\Models\SupportTicketCategory; +use App\Models\SupportTicketMessage; +use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; +use Throwable; class SupportTicketController extends Controller { public function index() { - return theme_view('support.index',[ - 'categories'=>SupportTicketCategory::where('active',true)->orderBy('sort_order')->get(), - 'faqs'=>Faq::limit(6)->get(), + return theme_view('support.index', [ + 'categories' => SupportTicketCategory::where('active', true)->orderBy('sort_order')->get(), + 'faqs' => Faq::limit(6)->get(), ]); } public function tickets(Request $request) { - $q=SupportTicket::with('category')->where('requester_type','user')->where('requester_id',auth()->id()); - if($request->filled('status')) $q->where('status',$request->status); - if($request->filled('search')){ - $s='%'.$request->search.'%'; - $q->where(fn($x)=>$x->where('ticket_number','like',$s)->orWhere('subject','like',$s)); - } - return theme_view('support.tickets',['tickets'=>$q->orderByDesc('last_reply_at')->orderByDesc('id')->paginate(15)->withQueryString()]); - } - - public function create() - { - return theme_view('support.create',['categories'=>SupportTicketCategory::where('active',true)->orderBy('sort_order')->get()]); + $user = $this->profileUser($request); + if (!$request->routeIs('user.support.*')) { + return redirect()->route('user.support.index', [ + 'username' => strtolower($user->username), + ...$request->only('status', 'search', 'page'), + ]); + } + + $groups = $this->filterGroups(); + $data = $request->validate([ + 'search' => ['nullable', 'string', 'max:180'], + 'status' => ['nullable', 'string', Rule::in(array_unique([ + 'all', ...array_keys($groups), ...array_keys(SupportTicket::statuses()), + ]))], + ]); + $status = $data['status'] ?? 'all'; + $search = trim($data['search'] ?? ''); + $query = $this->ownedQuery($user)->with('category'); + + if ($status !== 'all') { + $query->whereIn('status', $groups[$status] ?? [$status]); + } + if ($search !== '') { + // The nested OR must stay inside the requester scope. + $query->where(function (Builder $q) use ($search) { + $q->where('ticket_number', 'like', '%'.$search.'%') + ->orWhere('subject', 'like', '%'.$search.'%'); + }); + } + + $rawCounts = $this->ownedQuery($user)->select('status') + ->selectRaw('COUNT(*) AS aggregate')->groupBy('status')->pluck('aggregate', 'status'); + $counts = ['all' => (int) $rawCounts->sum()]; + foreach ($groups as $key => $values) { + $counts[$key] = (int) collect($values)->sum(fn ($value) => (int) $rawCounts->get($value, 0)); + } + + return view('themes.ayline.user.support.index', [ + 'user' => $user, + 'tickets' => $query->orderByDesc('updated_at')->orderByDesc('id')->paginate(10)->withQueryString(), + 'counts' => $counts, 'status' => $status, 'search' => $search, + ]); + } + + public function create(Request $request) + { + $user = $this->profileUser($request); + if (!$request->routeIs('user.support.*')) { + return redirect()->route('user.support.create', [ + 'username' => strtolower($user->username), + ...$request->only('category'), + ]); + } + + return view('themes.ayline.user.support.create', [ + 'user' => $user, + 'categories' => SupportTicketCategory::where('active', true)->orderBy('sort_order')->get(), + ]); } public function store(Request $request) { - $data=$request->validate([ - 'category_id'=>['required','exists:support_ticket_categories,id'], - 'subject'=>['required','string','max:180'], - 'description'=>['required','string','min:10','max:10000'], - 'priority'=>['required',Rule::in(array_keys(SupportTicket::priorities()))], - 'attachments.*'=>['nullable','file','max:10240','mimes:jpg,jpeg,png,webp,pdf,txt,doc,docx'], - ]); - - $ticket=SupportTicket::create([ - 'requester_type'=>'user','requester_id'=>auth()->id(),'category_id'=>$data['category_id'], - 'subject'=>$data['subject'],'description'=>$data['description'],'priority'=>$data['priority'], - 'status'=>SupportTicket::STATUS_NEW,'last_reply_at'=>now(), - 'sla_due_at'=>now()->addHours($data['priority']==='urgent'?8:($data['priority']==='important'?24:48)), - ]); - - $message=$ticket->messages()->create(['sender_type'=>'user','sender_id'=>auth()->id(),'message'=>$data['description']]); - $this->saveFiles($request,$ticket,$message->id); - - toastr()->success(getLocale()==='sq'?'Kërkesa u hap me sukses':'Ticket created successfully'); - return redirect()->route('support.tickets.show',$ticket->ticket_number); - } - - public function show(string $number) - { - $ticket=$this->mine($number); - $ticket->load(['category','messages.attachments','assignedAdmin','business']); - return theme_view('support.show',compact('ticket')); - } - - public function reply(Request $request,string $number) - { - $ticket=$this->mine($number); - abort_if($ticket->status===SupportTicket::STATUS_CLOSED,403); - $data=$request->validate([ - 'message'=>['required','string','max:10000'], - 'attachments.*'=>['nullable','file','max:10240','mimes:jpg,jpeg,png,webp,pdf,txt,doc,docx'] - ]); - - $message=$ticket->messages()->create(['sender_type'=>'user','sender_id'=>auth()->id(),'message'=>$data['message']]); - $ticket->update(['status'=>SupportTicket::STATUS_WAITING_STAFF,'last_reply_at'=>now()]); - $this->saveFiles($request,$ticket,$message->id); - toastr()->success(getLocale()==='sq'?'Përgjigjja u dërgua':'Reply sent'); - return back(); - } - - public function close(string $number) - { - $this->mine($number)->update(['status'=>SupportTicket::STATUS_CLOSED,'closed_at'=>now()]); - return back(); - } - - public function reopen(string $number) - { - $this->mine($number)->update(['status'=>SupportTicket::STATUS_WAITING_STAFF,'closed_at'=>null,'resolved_at'=>null,'last_reply_at'=>now()]); - return back(); - } - - public function rate(Request $request,string $number) - { - $data=$request->validate(['rating'=>['required','integer','between:1,5'],'comment'=>['nullable','string','max:1000']]); - $this->mine($number)->update(['satisfaction_rating'=>$data['rating'],'satisfaction_comment'=>$data['comment']??null]); - return back(); - } - - public function attachment(string $number,int $attachment) - { - $ticket=$this->mine($number); - $file=$ticket->attachments()->findOrFail($attachment); - abort_unless(Storage::disk('private')->exists($file->path),404); - return Storage::disk('private')->download($file->path,$file->original_name); - } - - private function mine(string $number): SupportTicket - { - return SupportTicket::where('ticket_number',$number)->where('requester_type','user')->where('requester_id',auth()->id())->firstOrFail(); - } - - private function saveFiles(Request $request,SupportTicket $ticket,?int $messageId=null): void - { - foreach($request->file('attachments',[]) as $file){ - $path=$file->store('support-tickets/'.$ticket->id,'private'); - SupportTicketAttachment::create([ - 'ticket_id'=>$ticket->id,'message_id'=>$messageId,'original_name'=>$file->getClientOriginalName(), - 'path'=>$path,'mime_type'=>$file->getMimeType(),'size'=>$file->getSize() + $user = $this->profileUser($request); + $data = $request->validate([ + 'category_id' => ['required', 'integer', Rule::exists('support_ticket_categories', 'id')->where('active', true)], + 'subject' => ['required', 'string', 'max:180', 'regex:/\S/u'], + 'description' => ['required', 'string', 'min:10', 'max:10000', 'regex:/\S/u'], + 'priority' => ['sometimes', 'required', Rule::in(array_keys(SupportTicket::priorities()))], + ...$this->uploadRules(), + ]); + + $ticket = null; + $message = null; + try { + $priority = $data['priority'] ?? SupportTicket::PRIORITY_NORMAL; + $ticket = SupportTicket::create([ + 'requester_type' => 'user', 'requester_id' => $user->id, + 'category_id' => $data['category_id'], 'subject' => trim($data['subject']), + 'description' => trim($data['description']), 'priority' => $priority, + 'status' => SupportTicket::STATUS_NEW, 'last_reply_at' => now(), + 'sla_due_at' => now()->addHours($priority === 'urgent' ? 8 : ($priority === 'important' ? 24 : 48)), ]); + $message = $this->appendMessage($request, $ticket, $user, trim($data['description'])); + } catch (Throwable $exception) { + // The supplied deployment uses MyISAM for support tables; rollback alone is insufficient. + if ($message) $this->discardMessage($message); + if ($ticket) $ticket->delete(); + report($exception); + return back()->withInput($request->except('attachments')) + ->withErrors(['support' => __('user_support.save_failed')]); + } + + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.created')); + } + + public function show(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + + if (!$request->routeIs('user.support.*')) { + return $this->ticketRedirect($user, $ticket); + } + + $ticket->load(['category', 'business']); + $messages = $ticket->messages()->where('is_internal', false) + ->with(['attachments' => fn ($query) => $query->where('ticket_id', $ticket->id)]) + ->reorder()->orderByDesc('created_at')->orderByDesc('id') + ->paginate(30, ['*'], 'messages_page')->withQueryString(); + + return view('themes.ayline.user.support.show', [ + 'user' => $user, 'ticket' => $ticket, 'messages' => $messages, + 'legacyAttachments' => $ticket->attachments()->whereNull('message_id')->get(), + ]); + } + + public function reply(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + abort_if($ticket->status === SupportTicket::STATUS_CLOSED, 403); + $data = $request->validate([ + 'message' => ['required', 'string', 'max:10000', 'regex:/\S/u'], + ...$this->uploadRules(), + ]); + + $message = null; + try { + $message = $this->appendMessage($request, $ticket, $user, trim($data['message'])); + $ticket->update([ + 'status' => SupportTicket::STATUS_WAITING_STAFF, 'last_reply_at' => now(), + 'resolved_at' => null, 'closed_at' => null, + ]); + } catch (Throwable $exception) { + if ($message) $this->discardMessage($message); + report($exception); + return back()->withInput($request->except('attachments')) + ->withErrors(['support' => __('user_support.save_failed')]); + } + + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.reply_sent')); + } + + public function close(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + $ticket->update(['status' => SupportTicket::STATUS_CLOSED, 'closed_at' => now()]); + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.closed_success')); + } + + public function reopen(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + abort_unless(in_array($ticket->status, [SupportTicket::STATUS_CLOSED, SupportTicket::STATUS_RESOLVED], true), 409); + $ticket->update([ + 'status' => SupportTicket::STATUS_WAITING_STAFF, 'closed_at' => null, + 'resolved_at' => null, 'last_reply_at' => now(), + ]); + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.reopened')); + } + + public function rate(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + abort_unless(in_array($ticket->status, [SupportTicket::STATUS_RESOLVED, SupportTicket::STATUS_CLOSED], true), 403); + abort_if($ticket->satisfaction_rating !== null, 409); + $data = $request->validate([ + 'rating' => ['required', 'integer', 'between:1,5'], + 'comment' => ['nullable', 'string', 'max:1000'], + ]); + $ticket->update([ + 'satisfaction_rating' => $data['rating'], + 'satisfaction_comment' => $data['comment'] ?? null, + ]); + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.rated')); + } + + public function attachment(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + $attachmentId = $request->route('attachment'); + + $file = $ticket->attachments()->where(function (Builder $query) use ($ticket) { + $query->whereNull('message_id')->orWhereIn('message_id', + SupportTicketMessage::query()->select('id') + ->where('ticket_id', $ticket->id)->where('is_internal', false) + ); + })->findOrFail($attachmentId); + + // Also reject corrupt paths; never use request-supplied paths. + $path = str_replace('\\', '/', $file->path); + abort_if(str_starts_with($path, '/') || in_array('..', explode('/', $path), true), 404); + abort_unless(Storage::disk('private')->exists($path), 404); + return Storage::disk('private')->download($path, $file->original_name, [ + 'Cache-Control' => 'private, no-store, max-age=0', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + private function profileUser(Request $request): User + { + $user = $request->user('web'); + abort_unless($user instanceof User, 403); + $username = $request->route('username'); + abort_if($username !== null && strcasecmp((string) $username, (string) $user->username) !== 0, 404); + return $user; + } + + private function ownedQuery(User $user): Builder + { + return SupportTicket::query()->where('requester_type', 'user')->where('requester_id', $user->id); + } + + private function mine(Request $request, User $user): SupportTicket + { + return $this->ownedQuery($user)->where('ticket_number', $request->route('ticketNumber'))->firstOrFail(); + } + + private function ticketRedirect(User $user, SupportTicket $ticket) + { + return redirect()->route('user.support.show', [ + 'username' => strtolower($user->username), 'ticketNumber' => $ticket->ticket_number, + ]); + } + + private function filterGroups(): array + { + return [ + 'open' => [SupportTicket::STATUS_NEW, SupportTicket::STATUS_WAITING_STAFF], + 'in_progress' => [SupportTicket::STATUS_IN_PROGRESS], + 'answered' => [SupportTicket::STATUS_WAITING_USER], + 'resolved' => [SupportTicket::STATUS_RESOLVED], + 'closed' => [SupportTicket::STATUS_CLOSED], + ]; + } + + private function uploadRules(): array + { + return [ + 'attachments' => ['sometimes', 'array', 'max:5'], + 'attachments.*' => ['required', 'file', 'max:5120', + 'mimes:jpg,jpeg,png,webp,pdf', 'extensions:jpg,jpeg,png,webp,pdf'], + ]; + } + + private function appendMessage(Request $request, SupportTicket $ticket, User $user, string $text): SupportTicketMessage + { + $message = $ticket->messages()->create([ + 'sender_type' => 'user', 'sender_id' => $user->id, 'message' => $text, 'is_internal' => false, + ]); + $paths = []; + try { + foreach ($request->file('attachments', []) as $file) { + $path = $file->store('support-tickets/'.$ticket->id, 'private'); + if (!$path) throw new \RuntimeException('Private support attachment storage failed.'); + $paths[] = $path; + $name = basename(str_replace('\\', '/', $file->getClientOriginalName())); + $name = mb_substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'attachment', 0, 180); + SupportTicketAttachment::create([ + 'ticket_id' => $ticket->id, 'message_id' => $message->id, + 'original_name' => $name, 'path' => $path, + 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), + ]); + } + } catch (Throwable $exception) { + $message->attachments()->delete(); + $message->delete(); + $this->deleteStoredFiles($paths); + throw $exception; + } + return $message; + } + + private function discardMessage(SupportTicketMessage $message): void + { + $paths = $message->attachments()->pluck('path')->all(); + $message->attachments()->delete(); + $message->delete(); + $this->deleteStoredFiles($paths); + } + + private function deleteStoredFiles(array $paths): void + { + if ($paths === []) return; + try { + Storage::disk('private')->delete($paths); + } catch (Throwable $cleanupError) { + // A failed disk must not prevent cleanup of the MyISAM database rows. + report($cleanupError); } } } --- /dev/null +++ b/routes/user-support.php @@ -0,0 +1,20 @@ +prefix('{username}/support')->name('support.')->group(function () { + Route::get('/', [SupportTicketController::class, 'tickets'])->name('index'); + Route::get('new', [SupportTicketController::class, 'create'])->name('create'); + Route::post('/', [SupportTicketController::class, 'store'])->middleware('throttle:6,1')->name('store'); + Route::get('{ticketNumber}', [SupportTicketController::class, 'show'])->name('show'); + Route::post('{ticketNumber}/reply', [SupportTicketController::class, 'reply'])->middleware('throttle:10,1')->name('reply'); + Route::post('{ticketNumber}/close', [SupportTicketController::class, 'close'])->middleware('throttle:10,1')->name('close'); + Route::post('{ticketNumber}/reopen', [SupportTicketController::class, 'reopen'])->middleware('throttle:10,1')->name('reopen'); + Route::post('{ticketNumber}/rate', [SupportTicketController::class, 'rate'])->middleware('throttle:10,1')->name('rate'); + Route::get('{ticketNumber}/attachments/{attachment}', [SupportTicketController::class, 'attachment']) + ->whereNumber('attachment')->middleware('throttle:60,1')->name('attachment'); + }); --- a/routes/web.php +++ b/routes/web.php @@ -92,6 +92,7 @@ Route::post('{slug}', 'GeneralController@blogComment')->name('article')->middleware('auth'); }); Route::name('user.')->prefix('user')->group(function () { + require __DIR__.'/user-support.php'; Route::get('{username}', 'UserController@profile')->name('profile'); Route::middleware(['demo', 'auth'])->group(function () { Route::name('favorites.')->prefix('{username}/favorites')->group(function () { @@ -113,15 +114,15 @@ }); Route::get('support', 'SupportTicketController@index')->name('support.index'); - Route::middleware('auth')->name('support.')->prefix('support')->group(function () { + Route::middleware(['auth', \App\Http\Middleware\PrivateSupportResponse::class])->name('support.')->prefix('support')->group(function () { Route::get('tickets', 'SupportTicketController@tickets')->name('tickets.index'); Route::get('tickets/new', 'SupportTicketController@create')->name('tickets.create'); - Route::post('tickets', 'SupportTicketController@store')->name('tickets.store'); + Route::post('tickets', 'SupportTicketController@store')->middleware('throttle:6,1')->name('tickets.store'); Route::get('tickets/{ticketNumber}', 'SupportTicketController@show')->name('tickets.show'); - Route::post('tickets/{ticketNumber}/reply', 'SupportTicketController@reply')->name('tickets.reply'); - Route::post('tickets/{ticketNumber}/close', 'SupportTicketController@close')->name('tickets.close'); - Route::post('tickets/{ticketNumber}/reopen', 'SupportTicketController@reopen')->name('tickets.reopen'); - Route::post('tickets/{ticketNumber}/rate', 'SupportTicketController@rate')->name('tickets.rate'); + Route::post('tickets/{ticketNumber}/reply', 'SupportTicketController@reply')->middleware('throttle:10,1')->name('tickets.reply'); + Route::post('tickets/{ticketNumber}/close', 'SupportTicketController@close')->middleware('throttle:10,1')->name('tickets.close'); + Route::post('tickets/{ticketNumber}/reopen', 'SupportTicketController@reopen')->middleware('throttle:10,1')->name('tickets.reopen'); + Route::post('tickets/{ticketNumber}/rate', 'SupportTicketController@rate')->middleware('throttle:10,1')->name('tickets.rate'); Route::get('tickets/{ticketNumber}/attachments/{attachment}', 'SupportTicketController@attachment')->name('tickets.attachment'); }); --- a/resources/views/themes/ayline/user/layout.blade.php +++ b/resources/views/themes/ayline/user/layout.blade.php @@ -3,9 +3,11 @@
@include('themes.ayline.includes.head') + + - +routeIs('user.support.*')) data-private-support="true" @endif> @include('themes.ayline.includes.navbar'){{ __('user_support.files_help') }}
+ +{{ __('user_support.intro') }}
{{ __('user_support.private') }}
{{ \Illuminate\Support\Str::limit(preg_replace('/\s+/u', ' ', $ticket->description), 120) }}
+{{ $ticket->category?->name ?? __('user_support.category_missing') }} + {{ __('user_support.updated_at') }}: {{ $ticket->updated_at?->format('d.m.Y H:i') }} +
+{{ $counts['all'] === 0 ? __('user_support.empty_text') : __('user_support.no_results_text') }}
+ @if($counts['all'] > 0) + {{ __('user_support.reset') }} + @endif +{{ __('user_support.create_intro') }}
+ @include('themes.ayline.user.support._feedback') + @if($categories->isEmpty()) +{{ __('user_support.message_pages') }}
+{{ __('user_support.your_rating') }}: {{ $ticket->satisfaction_rating }} / 5
+ @endif +{{ __('user_support.private') }}
+