Merge branch 'master' of github.com:freescout-helpdesk/freescout into dist

This commit is contained in:
FreeScout
2026-08-23 22:28:24 -07:00
27 changed files with 761 additions and 38 deletions

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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;

View 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;
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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()
) {

View File

@@ -94,6 +94,10 @@ class EventServiceProvider extends ServiceProvider
'App\Listeners\SendNotificationToUsers',
'App\Listeners\RefreshConversations',
],
'App\Events\UserMovedConversation' => [
'App\Listeners\SendNotificationToUsers',
],
];
/**

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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",

View File

@@ -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',
/*
|--------------------------------------------------------------------------

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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.
*

View File

@@ -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

View File

@@ -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
View File

@@ -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()

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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;">&lt;'.$from_header.'&gt;</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>';
}

View File

@@ -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')