Merge branch 'master' of github.com:freescout-helpdesk/freescout into dist
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
Read about **[FreeScout's Security](https://freescout.net/security/)**.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
* Report a security vulnerability in the **Application Core**: https://github.com/freescout-help-desk/freescout/security/advisories
|
||||
|
||||
@@ -20,7 +20,7 @@ class CleanTmp extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Remove from system temp folder FreeScout files older than 1 week to avoid "No space left on device"';
|
||||
protected $description = 'Remove from system temp folder old FreeScout tempt files to avoid "No space left on device"';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
@@ -39,8 +39,15 @@ class CleanTmp extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// General cleaning.
|
||||
// Remove 1 week old FreeScout files.
|
||||
\Helper::shellExec('find '.\Helper::getTempDir().' -mtime +7 -type f -name '.\Helper::getTempFilePrefix().'* -exec rm -r -f {} \;');
|
||||
|
||||
// Remove temporary 1 day old SwiftMailer files.
|
||||
// Example: /tmp/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6/body
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5558
|
||||
\Helper::shellExec('find '.\Helper::getTempDir().' -mtime +1 -type d -regextype posix-extended -regex ".*/[a-f0-9]{32}" -exec rm -r -f {} \;');
|
||||
|
||||
$this->comment("Done");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ class FetchEmails extends Command
|
||||
// (when there are many mailboxes for example),
|
||||
// we increase connection sleep time and retry after sleep.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4227
|
||||
if (trim($e->getMessage()) == 'connection setup failed') {
|
||||
if (stristr($e->getMessage(), 'connection setup failed')) {
|
||||
$sleep += 500000;
|
||||
|
||||
usleep(self::MAX_SLEEP);
|
||||
@@ -594,8 +594,12 @@ class FetchEmails extends Command
|
||||
|
||||
if ($is_bounce && !$bounced_message_id) {
|
||||
foreach ($attachments as $attachment_msg) {
|
||||
// 7.3.1 The Message/rfc822 (primary) subtype. A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message, with the syntax of an RFC 822 message
|
||||
if ($attachment_msg->content_type == 'message/rfc822') {
|
||||
// message/rfc822: 7.3.1 The Message/rfc822 (primary) subtype.
|
||||
// A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message,
|
||||
// with the syntax of an RFC 822 message.
|
||||
//
|
||||
// text/rfc822-headers: https://github.com/freescout-help-desk/freescout/issues/5583
|
||||
if (in_array($attachment_msg->content_type, ['message/rfc822', 'text/rfc822-headers'])) {
|
||||
$bounced_message_id = \MailHelper::getHeader($attachment_msg->getContent(), 'message_id');
|
||||
if ($bounced_message_id) {
|
||||
break;
|
||||
|
||||
96
app/Console/Commands/ModuleMigrate.php
Normal file
96
app/Console/Commands/ModuleMigrate.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/**
|
||||
* php artisan module:migrate modulename.
|
||||
*
|
||||
* Replaces the command from nwidart/laravel-modules, which passes the module's
|
||||
* migrations folder to the migrate command after removing base_path() from it.
|
||||
* Module::getPath() is a realpath(), so on installations where the Modules
|
||||
* folder is a symlink leading outside of the application folder (a common
|
||||
* container layout: /var/www/html/Modules -> /data/Modules) base_path() does
|
||||
* not occur in that path. The value stays absolute, migrate prefixes
|
||||
* base_path() to it anyway (Illuminate\Database\Console\Migrations\
|
||||
* BaseCommand::getMigrationPaths()), the resulting folder does not exist and
|
||||
* the migration reports "Nothing to migrate" with a zero exit code.
|
||||
*
|
||||
* https://github.com/freescout-help-desk/freescout/issues/5569
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Nwidart\Modules\Commands\MigrateCommand;
|
||||
use Nwidart\Modules\Migrations\Migrator;
|
||||
use Nwidart\Modules\Module;
|
||||
|
||||
class ModuleMigrate extends MigrateCommand
|
||||
{
|
||||
/**
|
||||
* Run the migrations of the specified module.
|
||||
*
|
||||
* @param Module $module
|
||||
*/
|
||||
protected function migrate(Module $module)
|
||||
{
|
||||
$path = $this->getMigrationPath($module);
|
||||
|
||||
if ($this->option('subpath')) {
|
||||
$path = $path.'/'.$this->option('subpath');
|
||||
}
|
||||
|
||||
$this->call('migrate', [
|
||||
'--path' => $path,
|
||||
'--database' => $this->option('database'),
|
||||
'--pretend' => $this->option('pretend'),
|
||||
'--force' => $this->option('force'),
|
||||
]);
|
||||
|
||||
if ($this->option('seed')) {
|
||||
$this->call('module:seed', ['module' => $module->getName()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the module's migrations folder relative to base_path(), as the
|
||||
* migrate command prepends base_path() to whatever --path receives.
|
||||
*
|
||||
* @param Module $module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getMigrationPath(Module $module)
|
||||
{
|
||||
$absolute_path = (new Migrator($module))->getPath();
|
||||
|
||||
$path = str_replace(base_path(), '', $absolute_path);
|
||||
|
||||
if ($path != $absolute_path) {
|
||||
// The module is located inside of the application folder.
|
||||
return $path;
|
||||
}
|
||||
|
||||
// The module path is a realpath() leading outside of the application
|
||||
// folder. Rebuild the path from the folder the module has been scanned
|
||||
// from, which still contains the symlink and is therefore located
|
||||
// inside of the application folder.
|
||||
$module_path = $module->getPath();
|
||||
|
||||
if (method_exists($module, 'getScannedPath')
|
||||
&& $module_path
|
||||
&& strpos($absolute_path, $module_path) === 0
|
||||
) {
|
||||
$scanned_path = str_replace(base_path(), '', $module->getScannedPath());
|
||||
$scanned_path .= substr($absolute_path, strlen($module_path));
|
||||
|
||||
if (is_dir(base_path().DIRECTORY_SEPARATOR.$scanned_path)) {
|
||||
return $scanned_path;
|
||||
}
|
||||
}
|
||||
|
||||
// Neither path is located inside of the application folder, so the
|
||||
// migrate command can not resolve it. Tell the user instead of
|
||||
// reporting "Nothing to migrate".
|
||||
$this->error('Module migrations folder is located outside of the application folder: '.$absolute_path);
|
||||
$this->error('Run "php artisan migrate" to apply the module migrations.');
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Events\UserReplied;
|
||||
use App\Events\ConversationStatusChanged;
|
||||
use App\Events\ConversationUserChanged;
|
||||
use App\Events\ConversationCustomerChanged;
|
||||
use App\Events\UserMovedConversation;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Input;
|
||||
use Watson\Rememberable\Rememberable;
|
||||
@@ -1558,6 +1559,8 @@ class Conversation extends Model
|
||||
$prev_mailbox->updateFoldersCounters();
|
||||
$mailbox->updateFoldersCounters();
|
||||
|
||||
event(new UserMovedConversation($this, $this->getLastReply(), $user->id, $prev_mailbox));
|
||||
|
||||
\Eventy::action('conversation.moved', $this, $user, $prev_mailbox);
|
||||
|
||||
return true;
|
||||
@@ -2772,4 +2775,9 @@ class Conversation extends Model
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function isAssignedToUser($user)
|
||||
{
|
||||
return \Eventy::filter('conversation.is_user_assignee', $this->user_id == $user->id, $this, $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Events/UserMovedConversation.php
Normal file
28
app/Events/UserMovedConversation.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Conversation;
|
||||
use App\Mailbox;
|
||||
use App\Thread;
|
||||
|
||||
class UserMovedConversation
|
||||
{
|
||||
public $conversation;
|
||||
public $last_thread;
|
||||
public $caused_by_user_id;
|
||||
public $from_mailbox;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Conversation $conversation, Thread $last_thread, $caused_by_user_id, Mailbox $from_mailbox)
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->last_thread = $last_thread;
|
||||
$this->caused_by_user_id = $caused_by_user_id;
|
||||
$this->from_mailbox = $from_mailbox;
|
||||
}
|
||||
}
|
||||
@@ -2177,8 +2177,13 @@ class ConversationsController extends Controller
|
||||
|
||||
if (!$folder) {
|
||||
$response['msg'] = __('Folder not found');
|
||||
return \Response::json($response);
|
||||
}
|
||||
if (!$response['msg'] && !$folder->mailbox->userHasAccess($user->id)) {
|
||||
if (!in_array($folder->type, [Folder::TYPE_SPAM, Folder::TYPE_DELETED])) {
|
||||
$response['msg'] = __('Folder not found');
|
||||
return \Response::json($response);
|
||||
}
|
||||
if (!$folder->mailbox->userHasAccess($user->id)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
return \Response::json($response);
|
||||
}
|
||||
@@ -2195,7 +2200,22 @@ class ConversationsController extends Controller
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
$conversation_ids = Conversation::where('folder_id', $folder->id)->pluck('id')->toArray();
|
||||
// Do not allow users who can see only assigned conversations
|
||||
// delete any conversations in the folder.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6mhr-m8m9-6q6h
|
||||
if (!$user->isAdmin() && $user->canSeeOnlyAssignedConversations()) {
|
||||
// User can see (and selete) only assigned conversations.
|
||||
$conversation_ids = Conversation::where('folder_id', $folder->id)
|
||||
->get()
|
||||
->filter(function ($conversation) use ($user) {
|
||||
return $conversation->isAssignedToUser($user);
|
||||
})
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
} else {
|
||||
$conversation_ids = Conversation::where('folder_id', $folder->id)->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
Conversation::deleteConversationsForever($conversation_ids);
|
||||
if ($folder->mailbox) {
|
||||
Conversation::clearStarredByUserCache($user->id, $folder->mailbox_id);
|
||||
|
||||
@@ -51,7 +51,7 @@ class UsersController extends Controller
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', 'App\User');
|
||||
$mailboxes = Mailbox::all();
|
||||
$mailboxes = auth()->user()->mailboxesCanView();
|
||||
|
||||
return view('users/create', ['mailboxes' => $mailboxes]);
|
||||
}
|
||||
@@ -121,7 +121,15 @@ class UsersController extends Controller
|
||||
$user = \Eventy::filter('user.create_save', $user, $request);
|
||||
$user->save();
|
||||
|
||||
$user->mailboxes()->sync($request->mailboxes ?: []);
|
||||
$mailbox_ids = $request->mailboxes ?: [];
|
||||
|
||||
if (!$auth_user->isAdmin()) {
|
||||
// Exlude mailboxes to which current user has no access.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-w36p-9vhj-x273
|
||||
$mailbox_ids = array_intersect($mailbox_ids, $auth_user->mailboxesIdsCanView());
|
||||
}
|
||||
|
||||
$user->mailboxes()->sync($mailbox_ids);
|
||||
$user->syncPersonalFolders($request->mailboxes);
|
||||
|
||||
// Send invite
|
||||
|
||||
@@ -373,6 +373,8 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
->causedBy($this->customer)
|
||||
->withProperties([
|
||||
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
|
||||
'to' => $this->customer_email,
|
||||
'conversation' => '#'.$this->conversation->number.'-'.$this->last_thread->id,
|
||||
])
|
||||
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
|
||||
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
|
||||
@@ -632,6 +634,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
->withProperties([
|
||||
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
|
||||
'to' => $this->customer_email,
|
||||
'conversation' => '#'.$this->conversation->number.'-'.$this->last_thread->id,
|
||||
])
|
||||
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
|
||||
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
|
||||
|
||||
@@ -26,6 +26,7 @@ class SendNotificationToUsers
|
||||
{
|
||||
$event_type = null;
|
||||
$caused_by_user_id = null;
|
||||
$extra_data = [];
|
||||
|
||||
// Detect event type by event class
|
||||
switch (get_class($event)) {
|
||||
@@ -55,6 +56,11 @@ class SendNotificationToUsers
|
||||
$caused_by_user_id = $event->user->id;
|
||||
$event_type = Subscription::EVENT_TYPE_ASSIGNED;
|
||||
break;
|
||||
case 'App\Events\UserMovedConversation':
|
||||
$caused_by_user_id = $event->caused_by_user_id;
|
||||
$extra_data['from_mailbox'] = $event->from_mailbox;
|
||||
$event_type = Subscription::EVENT_TYPE_USER_MOVED;
|
||||
break;
|
||||
case 'App\Events\CustomerReplied':
|
||||
// Do not send notifications to users if customer sent a reply
|
||||
// to the conversation marked as Spam.
|
||||
@@ -75,6 +81,6 @@ class SendNotificationToUsers
|
||||
$conversation = $event->conversation;
|
||||
|
||||
// Using the last argument you can make event to be processed immediately
|
||||
Subscription::registerEvent($event_type, $conversation, $caused_by_user_id/*, true*/);
|
||||
Subscription::registerEvent($event_type, $conversation, $caused_by_user_id, $extra_data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,16 +753,19 @@ class Mail
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts both header name with "-" and "_".
|
||||
public static function getHeader($headers_str, $header)
|
||||
{
|
||||
$headers_str = $headers_str ?? '';
|
||||
|
||||
$header = strtolower($header);
|
||||
|
||||
// Quick check to same resources.
|
||||
// Quick check to save resources.
|
||||
$header = str_replace('_', '-', $header);
|
||||
if (!stristr($headers_str, $header)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$header = strtolower($header);
|
||||
$header = str_replace('-', '_', $header);
|
||||
|
||||
$headers = self::parseHeaders($headers_str);
|
||||
|
||||
@@ -119,7 +119,7 @@ class ConversationPolicy
|
||||
public function checkIsOnlyAssigned($conversation, $user)
|
||||
{
|
||||
// Maybe user can see only assigned conversations.
|
||||
if (!\Eventy::filter('conversation.is_user_assignee', $conversation->user_id == $user->id, $conversation, $user->id)
|
||||
if (!$conversation->isAssignedToUser($user)
|
||||
&& $conversation->created_by_user_id != $user->id
|
||||
&& $user->canSeeOnlyAssignedConversations()
|
||||
) {
|
||||
|
||||
@@ -94,6 +94,10 @@ class EventServiceProvider extends ServiceProvider
|
||||
'App\Listeners\SendNotificationToUsers',
|
||||
'App\Listeners\RefreshConversations',
|
||||
],
|
||||
|
||||
'App\Events\UserMovedConversation' => [
|
||||
'App\Listeners\SendNotificationToUsers',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ class Subscription extends Model
|
||||
const EVENT_TYPE_CUSTOMER_REPLIED = 4;
|
||||
const EVENT_TYPE_USER_REPLIED = 5;
|
||||
const EVENT_TYPE_USER_ADDED_NOTE = 6;
|
||||
const EVENT_TYPE_USER_MOVED = 7;
|
||||
|
||||
// Events
|
||||
// Notify me when…
|
||||
@@ -187,6 +188,7 @@ class Subscription extends Model
|
||||
|
||||
switch ($event_type) {
|
||||
case self::EVENT_TYPE_NEW:
|
||||
case self::EVENT_TYPE_USER_MOVED:
|
||||
$events[] = self::EVENT_NEW_CONVERSATION;
|
||||
break;
|
||||
|
||||
@@ -221,7 +223,7 @@ class Subscription extends Model
|
||||
|
||||
// Check if assigned user changed
|
||||
$user_changed = false;
|
||||
if ($event_type != self::EVENT_TYPE_ASSIGNED && $event_type != self::EVENT_TYPE_NEW) {
|
||||
if (!in_array($event_type, [self::EVENT_TYPE_ASSIGNED, self::EVENT_TYPE_NEW, self::EVENT_TYPE_USER_MOVED])) {
|
||||
if ($thread->type == Thread::TYPE_LINEITEM && $thread->action_type == Thread::ACTION_TYPE_USER_CHANGED) {
|
||||
$user_changed = true;
|
||||
} elseif ($prev_thread) {
|
||||
@@ -243,7 +245,7 @@ class Subscription extends Model
|
||||
$events = array_unique($events);
|
||||
|
||||
// Detect subscribed users
|
||||
if (!$mailbox_user_ids) {
|
||||
if ($mailbox_user_ids === null) {
|
||||
$mailbox_user_ids = $conversation->mailbox->userIdsHavingAccess();
|
||||
}
|
||||
|
||||
@@ -255,7 +257,7 @@ class Subscription extends Model
|
||||
|
||||
// Filter subscribers
|
||||
foreach ($subscriptions as $i => $subscription) {
|
||||
// Actions on conversation where user is assignee
|
||||
// Actions on conversation where user must be assignee
|
||||
if (in_array($subscription->event, [self::EVENT_CONVERSATION_ASSIGNED_TO_ME, self::EVENT_CUSTOMER_REPLIED_TO_MY, self::EVENT_USER_REPLIED_TO_MY])
|
||||
&& ($conversation->user_id != $subscription->user_id && !\Eventy::filter('subscription.is_user_assignee', false, $subscription, $conversation))
|
||||
) {
|
||||
@@ -316,18 +318,30 @@ class Subscription extends Model
|
||||
|
||||
// Collect into notify array information about all users who need to be notified
|
||||
foreach (self::$occurred_events as $event) {
|
||||
// Get mailbox users ids
|
||||
$mailbox_user_ids = [];
|
||||
// Get mailbox users ids (from previous $notify record).
|
||||
/*$mailbox_user_ids = null;
|
||||
foreach (self::$mediums as $medium) {
|
||||
if (!empty($notify[$medium])) {
|
||||
foreach ($notify[$medium] as $conversation_id => $notify_info) {
|
||||
if ($notify_info['conversation']->mailbox_id == $event['conversation']->mailbox_id) {
|
||||
$mailbox_user_ids = $notify_info['mailbox_user_ids'];
|
||||
foreach ($notify[$medium] as $conversation_id => $notify_row) {
|
||||
if ($notify_row['conversation']->mailbox_id == $event['conversation']->mailbox_id) {
|
||||
$mailbox_user_ids = $notify_row['mailbox_user_ids'];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($mailbox_user_ids === null) {
|
||||
$mailbox_user_ids = $event['conversation']->mailbox->userIdsHavingAccess();
|
||||
}*/
|
||||
$mailbox_user_ids = $event['conversation']->mailbox->userIdsHavingAccess();
|
||||
|
||||
// When conversation is moved to another mailbox
|
||||
// notify only users NOT having access to the original mailbox.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5580
|
||||
if ($event['event_type'] == self::EVENT_TYPE_USER_MOVED && !empty($event['extra_data']['mailbox'])) {
|
||||
$original_mailbox_user_ids = $event['extra_data']['mailbox']->userIdsHavingAccess();
|
||||
$mailbox_user_ids = array_diff($mailbox_user_ids, $original_mailbox_user_ids);
|
||||
}
|
||||
|
||||
// Get users and threads from previous results to avoid repeated SQL queries.
|
||||
$users = [];
|
||||
@@ -364,7 +378,7 @@ class Subscription extends Model
|
||||
'users' => array_unique(array_merge($users[$medium] ?? [], $medium_users_to_notify)),
|
||||
'conversation' => $event['conversation'],
|
||||
'threads' => $threads,
|
||||
'mailbox_user_ids' => $mailbox_user_ids,
|
||||
//'mailbox_user_ids' => $mailbox_user_ids,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -478,12 +492,13 @@ class Subscription extends Model
|
||||
/**
|
||||
* Remember event type to process in ProcessSubscriptionEvents middleware on terminate.
|
||||
*/
|
||||
public static function registerEvent($event_type, $conversation, $caused_by_user_id, $process_now = false)
|
||||
public static function registerEvent($event_type, $conversation, $caused_by_user_id, $extra_data = [], $process_now = false)
|
||||
{
|
||||
self::$occurred_events[] = [
|
||||
'event_type' => $event_type,
|
||||
'conversation' => $conversation,
|
||||
'caused_by_user_id' => $caused_by_user_id,
|
||||
'extra_data' => $extra_data,
|
||||
];
|
||||
|
||||
// Automatically add EVENT_TYPE_UPDATED
|
||||
@@ -492,6 +507,7 @@ class Subscription extends Model
|
||||
'event_type' => self::EVENT_TYPE_UPDATED,
|
||||
'conversation' => $conversation,
|
||||
'caused_by_user_id' => $caused_by_user_id,
|
||||
'extra_data' => $extra_data,
|
||||
];
|
||||
}
|
||||
if ($process_now) {
|
||||
|
||||
@@ -1476,8 +1476,49 @@ class Thread extends Model
|
||||
if (empty($this->headers)) {
|
||||
return '';
|
||||
}
|
||||
preg_match("#From:\s*.*[^\s]*\s*<\s*(.*[^\s])\s*>\s*\n#", $this->headers ?? '', $m);
|
||||
return $m[1] ?? '';
|
||||
preg_match("#From:\s*(?:.*?<\s*)?([^\s<>]+)(?:\s*>)?\s*\n#i", $this->headers ?? '', $m);
|
||||
return \Helper::sanitizeEmail($m[1] ?? '');
|
||||
}
|
||||
|
||||
// Used to highlight sender when From is different from Reply-To.
|
||||
public function getFromIfDifferentFromReplyTo($customer = null)
|
||||
{
|
||||
if (empty($this->headers) || !$this->isCustomerMessage()) {
|
||||
return '';
|
||||
}
|
||||
preg_match("#Reply\-To:\s*(?:.*?<\s*)?([^\s<>]+)(?:\s*>)?\s*\n#i", $this->headers ?? '', $m);
|
||||
|
||||
$reply_to = \Helper::sanitizeEmail($m[1] ?? '');
|
||||
|
||||
if (!$reply_to) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$from = $this->getFromHeader();
|
||||
if (!$from) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($from == $reply_to) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->created_by_customer_id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($this->created_by_customer_id != $customer->id) {
|
||||
if (!$this->created_by_customer) {
|
||||
return '';
|
||||
}
|
||||
$customer = $this->created_by_customer;
|
||||
}
|
||||
|
||||
if (!$customer->hasEmail($from)) {
|
||||
return $from;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getMailDate()
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"vendor/laravel/framework/src/Illuminate/Session/FileSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/NullSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/CacheBasedSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/CookieSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/DatabaseSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/Middleware/StartSession.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Queue/Listener.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Queue/SerializesModels.php",
|
||||
|
||||
@@ -18,7 +18,7 @@ return [
|
||||
| or any other location as required by the application or its packages.
|
||||
*/
|
||||
|
||||
'version' => '1.8.235',
|
||||
'version' => '1.8.236',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Illuminate\Session;
|
||||
|
||||
use SessionHandlerInterface;
|
||||
use Illuminate\Support\InteractsWithTime;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Illuminate\Contracts\Cookie\QueueingFactory as CookieJar;
|
||||
|
||||
class CookieSessionHandler implements SessionHandlerInterface
|
||||
{
|
||||
use InteractsWithTime;
|
||||
|
||||
/**
|
||||
* The cookie jar instance.
|
||||
*
|
||||
* @var \Illuminate\Contracts\Cookie\Factory
|
||||
*/
|
||||
protected $cookie;
|
||||
|
||||
/**
|
||||
* The request instance.
|
||||
*
|
||||
* @var \Symfony\Component\HttpFoundation\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* The number of minutes the session should be valid.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $minutes;
|
||||
|
||||
/**
|
||||
* Create a new cookie driven handler instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Cookie\QueueingFactory $cookie
|
||||
* @param int $minutes
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(CookieJar $cookie, $minutes)
|
||||
{
|
||||
$this->cookie = $cookie;
|
||||
$this->minutes = $minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function open($savePath, $sessionName): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function close(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* #[\\ReturnTypeWillChange]
|
||||
*/
|
||||
public function read($sessionId)
|
||||
{
|
||||
$value = $this->request->cookies->get($sessionId) ?: '';
|
||||
|
||||
if (! is_null($decoded = json_decode($value, true)) && is_array($decoded)) {
|
||||
if (isset($decoded['expires']) && $this->currentTime() <= $decoded['expires']) {
|
||||
return $decoded['data'];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function write($sessionId, $data): bool
|
||||
{
|
||||
$this->cookie->queue($sessionId, json_encode([
|
||||
'data' => $data,
|
||||
'expires' => $this->availableAt($this->minutes * 60),
|
||||
]), $this->minutes);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function destroy($sessionId): bool
|
||||
{
|
||||
$this->cookie->queue($this->cookie->forget($sessionId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* #[\\ReturnTypeWillChange]
|
||||
*/
|
||||
public function gc($lifetime)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the request instance.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* @return void
|
||||
*/
|
||||
public function setRequest(Request $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
namespace Illuminate\Session;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use SessionHandlerInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Contracts\Auth\Guard;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\InteractsWithTime;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
|
||||
class DatabaseSessionHandler implements SessionHandlerInterface, ExistenceAwareInterface
|
||||
{
|
||||
use InteractsWithTime;
|
||||
|
||||
/**
|
||||
* The database connection instance.
|
||||
*
|
||||
* @var \Illuminate\Database\ConnectionInterface
|
||||
*/
|
||||
protected $connection;
|
||||
|
||||
/**
|
||||
* The name of the session table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table;
|
||||
|
||||
/**
|
||||
* The number of minutes the session should be valid.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $minutes;
|
||||
|
||||
/**
|
||||
* The container instance.
|
||||
*
|
||||
* @var \Illuminate\Contracts\Container\Container
|
||||
*/
|
||||
protected $container;
|
||||
|
||||
/**
|
||||
* The existence state of the session.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $exists;
|
||||
|
||||
/**
|
||||
* Create a new database session handler instance.
|
||||
*
|
||||
* @param \Illuminate\Database\ConnectionInterface $connection
|
||||
* @param string $table
|
||||
* @param int $minutes
|
||||
* @param \Illuminate\Contracts\Container\Container|null $container
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(ConnectionInterface $connection, $table, $minutes, ?Container $container = null)
|
||||
{
|
||||
$this->table = $table;
|
||||
$this->minutes = $minutes;
|
||||
$this->container = $container;
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function open($savePath, $sessionName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function read($sessionId)
|
||||
{
|
||||
$session = (object) $this->getQuery()->find($sessionId);
|
||||
|
||||
if ($this->expired($session)) {
|
||||
$this->exists = true;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isset($session->payload)) {
|
||||
$this->exists = true;
|
||||
|
||||
return base64_decode($session->payload);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the session is expired.
|
||||
*
|
||||
* @param \stdClass $session
|
||||
* @return bool
|
||||
*/
|
||||
protected function expired($session)
|
||||
{
|
||||
return isset($session->last_activity) &&
|
||||
$session->last_activity < Carbon::now()->subMinutes($this->minutes)->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function write($sessionId, $data)
|
||||
{
|
||||
$payload = $this->getDefaultPayload($data);
|
||||
|
||||
if (! $this->exists) {
|
||||
$this->read($sessionId);
|
||||
}
|
||||
|
||||
if ($this->exists) {
|
||||
$this->performUpdate($sessionId, $payload);
|
||||
} else {
|
||||
$this->performInsert($sessionId, $payload);
|
||||
}
|
||||
|
||||
return $this->exists = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an insert operation on the session ID.
|
||||
*
|
||||
* @param string $sessionId
|
||||
* @param string $payload
|
||||
* @return bool|null
|
||||
*/
|
||||
protected function performInsert($sessionId, $payload)
|
||||
{
|
||||
try {
|
||||
return $this->getQuery()->insert(Arr::set($payload, 'id', $sessionId));
|
||||
} catch (QueryException $e) {
|
||||
$this->performUpdate($sessionId, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an update operation on the session ID.
|
||||
*
|
||||
* @param string $sessionId
|
||||
* @param string $payload
|
||||
* @return int
|
||||
*/
|
||||
protected function performUpdate($sessionId, $payload)
|
||||
{
|
||||
return $this->getQuery()->where('id', $sessionId)->update($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default payload for the session.
|
||||
*
|
||||
* @param string $data
|
||||
* @return array
|
||||
*/
|
||||
protected function getDefaultPayload($data)
|
||||
{
|
||||
$payload = [
|
||||
'payload' => base64_encode($data),
|
||||
'last_activity' => $this->currentTime(),
|
||||
];
|
||||
|
||||
if (! $this->container) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
return tap($payload, function (&$payload) {
|
||||
$this->addUserInformation($payload)
|
||||
->addRequestInformation($payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the user information to the session payload.
|
||||
*
|
||||
* @param array $payload
|
||||
* @return $this
|
||||
*/
|
||||
protected function addUserInformation(&$payload)
|
||||
{
|
||||
if ($this->container->bound(Guard::class)) {
|
||||
$payload['user_id'] = $this->userId();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently authenticated user's ID.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function userId()
|
||||
{
|
||||
return $this->container->make(Guard::class)->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the request information to the session payload.
|
||||
*
|
||||
* @param array $payload
|
||||
* @return $this
|
||||
*/
|
||||
protected function addRequestInformation(&$payload)
|
||||
{
|
||||
if ($this->container->bound('request')) {
|
||||
$payload = array_merge($payload, [
|
||||
'ip_address' => $this->ipAddress(),
|
||||
'user_agent' => $this->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IP address for the current request.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function ipAddress()
|
||||
{
|
||||
return $this->container->make('request')->ip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user agent for the current request.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function userAgent()
|
||||
{
|
||||
return substr((string) $this->container->make('request')->header('User-Agent'), 0, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function destroy($sessionId)
|
||||
{
|
||||
$this->getQuery()->where('id', $sessionId)->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function gc($lifetime)
|
||||
{
|
||||
$this->getQuery()->where('last_activity', '<=', $this->currentTime() - $lifetime)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a fresh query builder instance for the table.
|
||||
*
|
||||
* @return \Illuminate\Database\Query\Builder
|
||||
*/
|
||||
protected function getQuery()
|
||||
{
|
||||
return $this->connection->table($this->table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the existence state for the session.
|
||||
*
|
||||
* @param bool $value
|
||||
* @return $this
|
||||
*/
|
||||
public function setExists($value)
|
||||
{
|
||||
$this->exists = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,15 @@ abstract class Module extends ServiceProvider
|
||||
*/
|
||||
protected $path;
|
||||
|
||||
/**
|
||||
* The module path as it has been scanned, before realpath() resolved it.
|
||||
* When the Modules folder is a symlink leading outside of the application
|
||||
* folder, this is the only path which is still relative to base_path().
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $scanned_path;
|
||||
|
||||
/**
|
||||
* @var array of cached Json objects, keyed by filename
|
||||
*/
|
||||
@@ -49,6 +58,7 @@ abstract class Module extends ServiceProvider
|
||||
parent::__construct($app);
|
||||
$this->name = $name;
|
||||
$this->path = realpath($path);
|
||||
$this->scanned_path = $path;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +161,16 @@ abstract class Module extends ServiceProvider
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path the module has been scanned from, with symlinks not resolved.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getScannedPath()
|
||||
{
|
||||
return $this->scanned_path ?: $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set path.
|
||||
*
|
||||
|
||||
@@ -202,7 +202,11 @@ abstract class Repository implements RepositoryInterface, Countable
|
||||
$modules = [];
|
||||
|
||||
foreach ($cached as $name => $module) {
|
||||
$path = $module['path'];
|
||||
// Prefer the path the module has been scanned from, with symlinks
|
||||
// not resolved yet - the constructor resolves it again, so getPath()
|
||||
// stays the same, but the module also knows its original location.
|
||||
// Modules cached before this was stored only have 'path'.
|
||||
$path = !empty($module['scanned_path']) ? $module['scanned_path'] : $module['path'];
|
||||
|
||||
$modules[$name] = $this->createModule($this->app, $name, $path);
|
||||
}
|
||||
@@ -223,13 +227,22 @@ abstract class Repository implements RepositoryInterface, Countable
|
||||
|
||||
return $this->app['cache']->remember($this->config('cache.key'), $this->config('cache.lifetime'), function () {
|
||||
|
||||
$modules = $this->scan();
|
||||
|
||||
// By some reason when Nwidart\Modules\Module is converted into array
|
||||
$array = $this->toCollection()->toArray();
|
||||
$array = (new Collection($modules))->toArray();
|
||||
// Set `active` flag from DB for each module
|
||||
foreach ($array as $key => $item) {
|
||||
if (!empty($item['alias'])) {
|
||||
$item['active'] = (int) \App\Module::isActive($item['alias']);
|
||||
}
|
||||
|
||||
// Collection::toArray() only keeps the resolved path, but the
|
||||
// path the module has been scanned from is needed to build
|
||||
// migration paths relative to base_path().
|
||||
if (isset($modules[$key]) && method_exists($modules[$key], 'getScannedPath')) {
|
||||
$array[$key]['scanned_path'] = $modules[$key]->getScannedPath();
|
||||
}
|
||||
}
|
||||
|
||||
// Remember in memory cache to avoid reading cache file
|
||||
|
||||
8
public/css/bootstrap.css
vendored
8
public/css/bootstrap.css
vendored
@@ -3530,10 +3530,10 @@ tbody.collapse.in {
|
||||
height: 0;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
border-top: 4px dashed;
|
||||
border-top: 4px solid \9;
|
||||
border-right: 4px solid transparent;
|
||||
border-left: 4px solid transparent;
|
||||
border-top: 6px dashed;
|
||||
border-top: 5px solid \9;
|
||||
border-right: 5px solid transparent;
|
||||
border-left: 5px solid transparent;
|
||||
}
|
||||
.dropup,
|
||||
.dropdown {
|
||||
|
||||
16
public/js/main.js
vendored
16
public/js/main.js
vendored
@@ -577,14 +577,16 @@ function summernoteInit(selector, new_options)
|
||||
buttons: buttons,
|
||||
// Disable inserting HR tag.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4909
|
||||
keyMap: {
|
||||
//
|
||||
// Caused issue on Mac: https://github.com/freescout-help-desk/freescout/issues/5572
|
||||
/*keyMap: {
|
||||
pc: {
|
||||
'CTRL+ENTER': '' // Disable the shortcut on Windows/Linux
|
||||
},
|
||||
mac: {
|
||||
'CTRL+ENTER': '' // Disable the shortcut on Mac
|
||||
}
|
||||
},
|
||||
},*/
|
||||
callbacks: {
|
||||
onInit: function() {
|
||||
// Remove statusbar
|
||||
@@ -1152,6 +1154,7 @@ function initConversation()
|
||||
// Show
|
||||
prepareReplyForm();
|
||||
showReplyForm();
|
||||
fsDoAction('conversation.show_reply_form');
|
||||
} /*else {
|
||||
// Hide
|
||||
$(".conv-action-block").addClass('hidden');
|
||||
@@ -1776,14 +1779,16 @@ function convEditorInit()
|
||||
buttons: fs_conv_editor_buttons,
|
||||
// Disable inserting HR tag.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4909
|
||||
keyMap: {
|
||||
//
|
||||
// Caused issue on Mac: https://github.com/freescout-help-desk/freescout/issues/5572
|
||||
/*keyMap: {
|
||||
pc: {
|
||||
'CTRL+ENTER': '' // Disable the shortcut on Windows/Linux
|
||||
},
|
||||
mac: {
|
||||
'CTRL+ENTER': '' // Disable the shortcut on Mac
|
||||
}
|
||||
},
|
||||
},*/
|
||||
callbacks: {
|
||||
onImageUpload: function(files) {
|
||||
if (!files) {
|
||||
@@ -4853,6 +4858,8 @@ function getReplyBody()
|
||||
function setReplyBody(text)
|
||||
{
|
||||
$('#body').summernote("code", text);
|
||||
// Commit is needed for proper CTRL+Z functioning
|
||||
//$('#body').summernote('commit');
|
||||
if (text == fs_body_default) {
|
||||
text = '';
|
||||
}
|
||||
@@ -4863,6 +4870,7 @@ function setReplyBody(text)
|
||||
function setSummernoteText(jtextarea, text)
|
||||
{
|
||||
jtextarea.summernote("code", text);
|
||||
jtextarea.summernote("commit");
|
||||
}
|
||||
|
||||
function convListSortingInit()
|
||||
|
||||
2
public/js/summernote/summernote.js
vendored
2
public/js/summernote/summernote.js
vendored
@@ -7286,7 +7286,7 @@ $$1.summernote = $$1.extend($$1.summernote, {
|
||||
'CTRL+NUM4': 'formatH4',
|
||||
'CTRL+NUM5': 'formatH5',
|
||||
'CTRL+NUM6': 'formatH6',
|
||||
'CTRL+ENTER': 'insertHorizontalRule',
|
||||
//'CTRL+ENTER': 'insertHorizontalRule',
|
||||
'CTRL+K': 'linkDialog.show'
|
||||
},
|
||||
mac: {
|
||||
|
||||
@@ -106,9 +106,9 @@
|
||||
@action('thread.before_recipients', $thread, $loop, $threads, $conversation, $mailbox)
|
||||
@if (($thread->isUserMessage() && $thread->from && array_key_exists($thread->from, $mailbox->getAliases()))
|
||||
|| ($thread->isCustomerMessage() && isset($customer) && count($customer->emails) > 1)
|
||||
|| ($thread->isCustomerMessage() && ($from_header = $thread->getFromHeader()) && isset($customer) && !$customer->hasEmail($from_header) )
|
||||
|| ($thread->isCustomerMessage() && ($from_header = $thread->getFromIfDifferentFromReplyTo($customer ?? null)))
|
||||
)
|
||||
<div>
|
||||
<div @if (!empty($from_header)) class="text-warning" @endif>
|
||||
<strong>
|
||||
{{ __("From") }}:
|
||||
</strong>
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
@endif
|
||||
@php
|
||||
$trans_params = ['person' => '<strong style="color:#000000;">'.htmlspecialchars($thread->getCreatedBy()->getFullName(true)).'</strong>'];
|
||||
// Highlight sender when From is different from Reply-To.
|
||||
if ($thread->isCustomerMessage() &&
|
||||
($from_header = $thread->getFromIfDifferentFromReplyTo($customer ?? null))
|
||||
) {
|
||||
$trans_params['person'] .= ' <span style="color:#b37100;"><'.$from_header.'></span>';
|
||||
}
|
||||
if ($thread->isForwarded()) {
|
||||
$trans_params['forward_parent_conversation_number'] = '<a href="'.route('conversations.view', ['id' => $thread->getMetaFw(App\Thread::META_FORWARD_PARENT_CONVERSATION_ID)]).'#thread-'.htmlspecialchars($thread->getMetaFw(App\Thread::META_FORWARD_PARENT_THREAD_ID)).'">#'.htmlspecialchars($thread->getMetaFw(App\Thread::META_FORWARD_PARENT_CONVERSATION_NUMBER)).'</a>';
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@
|
||||
@foreach ($cols as $col)
|
||||
<td class="break-words">
|
||||
@if (isset($row[$col]))
|
||||
@php
|
||||
if ($col == 'conversation' && preg_match("/^#[0-9]+\-([0-9]+)$/", $row[$col], $m)) {
|
||||
$thread_id = $m[1] ?? '';
|
||||
if ($thread_id) {
|
||||
$row[$col] = App\Thread::find($thread_id) ?: $row[$col];
|
||||
}
|
||||
}
|
||||
if ($col == 'thread') {
|
||||
$row[$col] = App\Thread::find($row[$col]) ?: $row[$col];
|
||||
}
|
||||
@endphp
|
||||
@if ($col == 'user' || $col == 'customer')
|
||||
<a href="{{ $row[$col]->url() }}">{{ $row[$col]->getFullName(true) }}</a>
|
||||
@elseif ($col == 'date')
|
||||
|
||||
Reference in New Issue
Block a user