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

This commit is contained in:
FreeScout
2026-02-26 22:14:32 -08:00
29 changed files with 3492 additions and 73 deletions

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ Thumbs.db
/resources/lang/module.*
.phpunit.result.cache
/.phpunit.cache
.env.testing

View File

@@ -1845,10 +1845,14 @@ class ConversationsController extends Controller
$response['msg'] = __('Not enough permissions');
}
$conversation->changeCustomer($customer_email, null, $user);
if (!$response['msg']) {
$result = $conversation->changeCustomer($customer_email, null, $user);
$response['status'] = 'success';
\Session::flash('flash_success_floating', __('Customer changed'));
if ($result) {
$response['status'] = 'success';
\Session::flash('flash_success_floating', __('Customer changed'));
}
}
break;
@@ -2133,7 +2137,7 @@ class ConversationsController extends Controller
// Delete converations in a specific folder.
case 'empty_folder':
// At first, check if this user is able to delete conversations
if (!auth()->user()->isAdmin() && !auth()->user()->hasPermission(\App\User::PERM_DELETE_CONVERSATIONS)) {
if (!$user->isAdmin() && !$user->hasPermission(\App\User::PERM_DELETE_CONVERSATIONS)) {
$response['msg'] = __('Not enough permissions');
return \Response::json($response);
}
@@ -2150,6 +2154,10 @@ class ConversationsController extends Controller
$response['msg'] = __('Folder not found');
}
if (!$user->isAdmin() && $folder->mailbox && !$folder->mailbox->userHasAccess($user->id)) {
$response['msg'] = __('Not enough permissions');
}
if (!$response['msg']) {
$conversation_ids = Conversation::where('folder_id', $folder->id)->pluck('id')->toArray();
Conversation::deleteConversationsForever($conversation_ids);

View File

@@ -489,6 +489,8 @@ class CustomersController extends Controller
{
$customer = Customer::findOrFail($id);
$this->checkLimitVisibility($customer);
// $customers = Customer::where('id', '!=', $id)
// ->orderBy('first_name')
// ->orderBy('last_name')
@@ -512,6 +514,8 @@ class CustomersController extends Controller
$customer = Customer::findOrFail($id);
$customer2 = Customer::find($request->customer2_id);
$this->checkLimitVisibility($customer);
// Ensure customers are different
if ($id === $customer2->id) {
return redirect()->back()->with('error', __('Cannot merge the same customer'));

View File

@@ -980,7 +980,7 @@ class MailboxesController extends Controller
'in_out' => $in_out,
'state' => crc32($username.$password),
];
$url = \MailHelper::oauthGetAuthorizationUrl(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
$url = \MailHelper::oauthGetAuthorizationUrl($provider, [
'state' => json_encode($state),
'client_id' => $username,
]);
@@ -1005,7 +1005,7 @@ class MailboxesController extends Controller
} else {
// state is set.
// Try to get an access token (using the authorization code grant)
$token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
$token_data = \MailHelper::oauthGetAccessToken($provider, [
'client_id' => $username,
'client_secret' => $password,
'code' => $request->code,
@@ -1015,7 +1015,7 @@ class MailboxesController extends Controller
// Set username and password for the oppozite in_out.
if ($in_out == 'in') {
if (empty($mailbox->out_server)
|| (trim($mailbox->out_server) == \MailHelper::OAUTH_MICROSOFT_SMTP
|| ($mailbox->isOutServerOauth()
&& (!$mailbox->out_username || $mailbox->out_username == $username))
) {
$mailbox->out_username = $username;
@@ -1058,6 +1058,6 @@ class MailboxesController extends Controller
$route = 'mailboxes.connection';
}
return \MailHelper::oauthDisconnect($provider, route($route, ['id' => $mailbox_id]));
return \MailHelper::oauthDisconnect($provider, route($route, ['id' => $mailbox_id]), $mailbox);
}
}

View File

@@ -28,7 +28,7 @@ class HttpsRedirect {
public function handle($request, Closure $next)
{
if (\Helper::isHttps()) {
if (\Helper::isHttps() && !\Helper::isConsole()) {
//$request->setTrustedProxies( [ $request->getClientIp() ], array_keys($this->headers));
//!$request->secure()
if (!\Helper::isCurrentUrlHttps()) {

View File

@@ -73,7 +73,12 @@ class SendReplyToCustomer
// The reply may be sent to some other customer from previous threads.
// https://github.com/freescout-help-desk/freescout/pull/5199
if ($thread && ($customer_email = $thread->getToArray()[0]) && $customer_email != $main_customer_email) {
if ($thread
&& ($to_array = $thread->getToArray())
&& !empty($to_array[0])
&& ($customer_email = $to_array[0])
&& $customer_email != $main_customer_email
) {
$other_customer = Customer::getByEmail($customer_email);
if ($other_customer) {
$recipient_customer = $other_customer;

View File

@@ -990,7 +990,8 @@ class Mailbox extends Model
return $this->oauthEnabled()
&& $this->out_username !== null
&& $this->isOutUsernameOauth()
&& $this->out_server !== null && trim($this->out_server) == \MailHelper::OAUTH_MICROSOFT_SMTP;
&& $this->out_server !== null
&& $this->isOutServerOauth();
}
// For oAuth Username may have the following format:
@@ -1037,6 +1038,16 @@ class Mailbox extends Model
return (!strstr($this->out_username, '@') || preg_match("#.*@.*:.*#", $this->out_username));
}
public function isOauthProvider($provider)
{
return ($this->oauthGetParam('provider') == $provider);
}
public function isOutServerOauth()
{
return in_array(trim($this->out_server), [\MailHelper::OAUTH_MICROSOFT_SMTP, \MailHelper::OAUTH_GOOGLE_SMTP]);
}
public function setEmailAttribute($value)
{
if ($value) {

View File

@@ -1907,22 +1907,6 @@ class Helper
public static function sanitizeUploadedFileName($file_name, $uploaded_file = null, $contents = null)
{
// Check extension.
$ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if (preg_match('/('.implode('|', self::$restricted_extensions).')/', $ext) || mb_substr($file_name, 0, 1) == '.') {
// Add underscore to the extension if file has restricted extension.
$file_name = $file_name.'_';
} elseif ($ext == 'pdf') {
// Rename PDF to avoid running embedded JavaScript.
if ($uploaded_file && !$contents) {
$contents = file_get_contents($uploaded_file->getRealPath() ?: $uploaded_file->getPathname());
}
if ($contents && strstr($contents, '/JavaScript')) {
$file_name = $file_name.'_';
}
}
// Remove illegal chars.
$illegal_chars = [
// Unix.
@@ -1958,6 +1942,22 @@ class Helper
// https://github.com/freescout-help-desk/freescout/issues/2123#issuecomment-2775392740
$file_name = preg_replace("#\p{Cf}+#u", '', $file_name);
// Check extension.
$ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if (preg_match('/('.implode('|', self::$restricted_extensions).')/', $ext) || mb_substr($file_name, 0, 1) == '.') {
// Add underscore to the extension if file has restricted extension.
$file_name = $file_name.'_';
} elseif ($ext == 'pdf') {
// Rename PDF to avoid running embedded JavaScript.
if ($uploaded_file && !$contents) {
$contents = file_get_contents($uploaded_file->getRealPath() ?: $uploaded_file->getPathname());
}
if ($contents && strstr($contents, '/JavaScript')) {
$file_name = $file_name.'_';
}
}
return $file_name;
}

View File

@@ -51,6 +51,10 @@ class Mail
const OAUTH_PROVIDER_MICROSOFT = 'ms';
const OAUTH_MICROSOFT_SMTP = 'smtp.office365.com';
// Google Workspace
const OAUTH_PROVIDER_GOOGLE = 'gw';
const OAUTH_GOOGLE_SMTP = 'smtp.gmail.com';
/**
* If reply is not extracted properly from the incoming email, add here a new separator.
* Order is not important.
@@ -150,13 +154,17 @@ class Mail
if ($oauth) {
if ((strtotime($mailbox->oauthGetParam('issued_on')) + (int)$mailbox->oauthGetParam('expires_in')) < time()) {
// Try to get an access token (using the authorization code grant)
$token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
$token_data = \MailHelper::oauthGetAccessToken($mailbox->oauthGetParam('provider'), [
'client_id' => $mailbox->getOutOauthClientId(),
'client_secret' => $mailbox->out_password,
'refresh_token' => $mailbox->oauthGetParam('r_token'),
]);
if (!empty($token_data['a_token'])) {
// In Google Workspace new refresh token is not returned after refreshing the token.
if (empty($token_data['r_token'])) {
$token_data['r_token'] = $mailbox->oauthGetParam('r_token');
}
$mailbox->setMetaParam('oauth', $token_data, true);
} elseif (!empty($token_data['error'])) {
$error_message = 'Error occurred refreshing oAuth Access Token: '.$token_data['error'];
@@ -822,13 +830,17 @@ class Mail
if ($oauth) {
if ((strtotime($mailbox->oauthGetParam('issued_on')) + (int)$mailbox->oauthGetParam('expires_in')) < time()) {
// Try to get an access token (using the authorization code grant)
$token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
$token_data = \MailHelper::oauthGetAccessToken($mailbox->oauthGetParam('provider'), [
'client_id' => $mailbox->getInOauthClientId(),
'client_secret' => $mailbox->in_password,
'refresh_token' => $mailbox->oauthGetParam('r_token'),
]);
if (!empty($token_data['a_token'])) {
// In Google Workspace new refresh token is not returned after refreshing the token.
if (empty($token_data['r_token'])) {
$token_data['r_token'] = $mailbox->oauthGetParam('r_token');
}
$mailbox->setMetaParam('oauth', $token_data, true);
} elseif (!empty($token_data['error'])) {
$error_message = 'Error occurred refreshing oAuth Access Token: '.$token_data['error'];
@@ -947,6 +959,7 @@ class Mail
$args = [];
switch ($provider_code) {
case self::OAUTH_PROVIDER_MICROSOFT:
// https://docs.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
$args = [
@@ -954,10 +967,24 @@ class Mail
'response_type' => 'code',
'approval_prompt' => 'auto',
'redirect_uri' => route('mailboxes.oauth_callback'),
//'state' => // passed in $params
];
$args = array_merge($args, $params);
$url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?'.http_build_query($args);
break;
case self::OAUTH_PROVIDER_GOOGLE:
$args = [
'scope' => 'https://mail.google.com/',
'response_type' => 'code',
'prompt' => 'consent', // Without this there will be no refresh_token in response
'redirect_uri' => route('mailboxes.oauth_callback'),
'access_type' => 'offline', // Without this there will be no refresh_token in response
//'state' => // passed in $params
];
$args = array_merge($args, $params);
$url = 'https://accounts.google.com/o/oauth2/v2/auth?'.http_build_query($args);
break;
}
return $url;
@@ -969,6 +996,7 @@ class Mail
$post_params = [];
switch ($provider_code) {
case self::OAUTH_PROVIDER_MICROSOFT:
$post_params = [
'scope' => 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send',
@@ -1031,20 +1059,103 @@ class Mail
$token_data['error'] = 'Response code: '.curl_getinfo($curl, CURLINFO_HTTP_CODE);
}
}
curl_close($curl);
if (PHP_VERSION_ID < 80000) {
curl_close($curl);
}
break;
case self::OAUTH_PROVIDER_GOOGLE:
$post_params = [
//'scope' => 'https://mail.google.com/', // Just in case
"grant_type" => "authorization_code",
'redirect_uri' => route('mailboxes.oauth_callback'),
];
$post_params = array_merge($post_params, $params);
// Refreshing Access Token.
if (!empty($post_params['refresh_token'])) {
$post_params['grant_type'] = 'refresh_token';
}
$full_url = "https://oauth2.googleapis.com/token";
$curl = curl_init($full_url);
curl_setopt($curl, CURLOPT_POST, true);
//curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
curl_setopt($curl, CURLOPT_POSTFIELDS, $post_params);
curl_setopt($curl, CURLOPT_HTTPHEADER, array("application/x-www-form-urlencoded"));
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
\Helper::setCurlDefaultOptions($curl);
curl_setopt($curl, CURLOPT_TIMEOUT, 180);
$response = curl_exec($curl);
if ($response) {
$result = json_decode($response, true);
// {
// "access_token": "...",
// "refresh_token": "...",
// "expires_in": 3598,
// "scope": "https://mail.google.com/",
// "token_type": "Bearer"
// }
if (!empty($result['access_token'])) {
$token_data['provider'] = self::OAUTH_PROVIDER_GOOGLE;
$token_data['a_token'] = $result['access_token'];
if ($post_params['grant_type'] != 'refresh_token') {
$token_data['r_token'] = $result['refresh_token'];
}
//$token_data['id_token'] = $result['id_token'];
$token_data['issued_on'] = now()->toDateTimeString();
$token_data['expires_in'] = $result['expires_in'];
} elseif ($response) {
$token_data['error'] = $response;
} else {
$token_data['error'] = 'Response code: '.curl_getinfo($curl, CURLINFO_HTTP_CODE);
}
}
if (PHP_VERSION_ID < 80000) {
curl_close($curl);
}
break;
}
return $token_data;
}
public static function oauthDisconnect($provider_code, $redirect_uri)
public static function oauthDisconnect($provider_code, $redirect_uri, $mailbox)
{
switch ($provider_code) {
case self::OAUTH_PROVIDER_MICROSOFT:
return redirect()->away('https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri='.urlencode($redirect_uri));
break;
case self::OAUTH_PROVIDER_GOOGLE:
// This does not work.
// $post_params = [
// 'token' => $mailbox->oauthGetParam('a_token')
// ];
// $full_url = "https://oauth2.googleapis.com/revoke";
// $curl = curl_init($full_url);
// curl_setopt($curl, CURLOPT_POST, true);
// curl_setopt($curl, CURLOPT_POSTFIELDS, $post_params);
// curl_setopt($curl, CURLOPT_HTTPHEADER, array("application/x-www-form-urlencoded"));
// curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
// \Helper::setCurlDefaultOptions($curl);
// curl_setopt($curl, CURLOPT_TIMEOUT, 180);
// $response = curl_exec($curl);
// $http_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
// if ($http_status != 200) {
// \Log::error('[Google Workspace OAuth] Error revoking token on logout: HTTPS Status Code - '.$http_status.'; '.json_encode($response));
// }
return redirect()->away($redirect_uri);
}
}

View File

@@ -82,7 +82,8 @@
"classmap": [
"database/seeds",
"database/factories",
"overrides/natxet/cssmin/src/"
"overrides/natxet/cssmin/src/",
"overrides/erusev/parsedown/"
],
"psr-0": {
"Swift_": "overrides/swiftmailer/swiftmailer/lib/classes",
@@ -198,6 +199,8 @@
"Symfony\\Component\\Console\\Exception\\": "overrides/symfony/console/Exception/",
"Symfony\\Component\\Console\\Question\\": "overrides/symfony/console/",
"Symfony\\Component\\CssSelector\\XPath\\Extension\\": "overrides/symfony/css-selector/XPath/Extension/",
"Symfony\\Component\\CssSelector\\XPath\\": "overrides/symfony/css-selector/XPath/",
"Symfony\\Component\\CssSelector\\Parser\\": "overrides/symfony/css-selector/Parser/",
"Symfony\\Component\\Translation\\Loader\\": "overrides/symfony/translation/Loader/",
"Symfony\\Component\\Translation\\Formatter\\": "overrides/symfony/translation/Formatter/",
"Symfony\\Component\\Translation\\": "overrides/symfony/translation/",
@@ -215,7 +218,9 @@
"Psy\\TimeitCommand\\": "overrides/psy/psysh/src/TimeitCommand/",
"Psy\\Command\\ListCommand\\": "overrides/psy/psysh/src/Command/ListCommand/",
"Psy\\Exception\\": "overrides/psy/psysh/src/ErrorException/",
"Psy\\VarDumper\\": "overrides/psy/psysh/src/VarDumper/"
"Psy\\VarDumper\\": "overrides/psy/psysh/src/VarDumper/",
"TijsVerkoyen\\CssToInlineStyles\\": "overrides/tijsverkoyen/css-to-inline-styles/src/",
"TijsVerkoyen\\CssToInlineStyles\\Css\\Property\\": "overrides/tijsverkoyen/css-to-inline-styles/src/Css/Property/"
},
"exclude-from-classmap": [
"Modules/",
@@ -265,6 +270,7 @@
"vendor/chumper/zipper/src/Chumper/Zipper/Repositories/ZipRepository.php",
"vendor/fzaninotto/faker/src/Faker/Provider/Base.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrDef/URI/Host.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrDef/CSS/FontFamily.php",
"vendor/laravel/framework/src/Illuminate/Database/DetectsLostConnections.php",
"vendor/laravel/framework/src/Illuminate/Support/Collection.php",
@@ -290,6 +296,7 @@
"vendor/laravel/framework/src/Illuminate/Pagination/LengthAwarePaginator.php",
"vendor/laravel/framework/src/Illuminate/Routing/RouteCollection.php",
"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/Middleware/StartSession.php",
"vendor/laravel/framework/src/Illuminate/Queue/Listener.php",
@@ -397,6 +404,8 @@
"vendor/symfony/console/Question/Question.php",
"vendor/symfony/finder/Iterator/SortableIterator.php",
"vendor/symfony/css-selector/XPath/Extension/NodeExtension.php",
"vendor/symfony/css-selector/XPath/Translator.php",
"vendor/symfony/css-selector/Parser/Parser.php",
"vendor/symfony/translation/Loader/ArrayLoader.php",
"vendor/symfony/translation/Formatter/MessageFormatter.php",
"vendor/symfony/translation/Translator.php",
@@ -497,7 +506,11 @@
"vendor/psy/psysh/src/Exception/BreakException.php",
"vendor/psy/psysh/src/VarDumper/Dumper.php",
"vendor/laravel/framework/src/Illuminate/Foundation/Application.php",
"vendor/laravel/framework/src/Illuminate/Contracts/Container/Container.php"
"vendor/laravel/framework/src/Illuminate/Contracts/Container/Container.php",
"vendor/erusev/parsedown/Parsedown.php",
"vendor/tijsverkoyen/css-to-inline-styles/src/CssToInlineStyles.php",
"vendor/tijsverkoyen/css-to-inline-styles/src/Css/Property/Processor.php",
"vendor/tijsverkoyen/css-to-inline-styles/src/Css/Property/Property.php"
]
},
"autoload-dev": {

View File

@@ -18,7 +18,7 @@ return [
| or any other location as required by the application or its packages.
*/
'version' => '1.8.206',
'version' => '1.8.207',
/*
|--------------------------------------------------------------------------

View File

@@ -55,15 +55,16 @@ return [
'options' => extension_loaded('pdo_mysql') ? array_filter([
(defined('Pdo\Mysql::ATTR_SSL_CA') ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('DB_MYSQL_ATTR_SSL_CA'),
(defined('Pdo\Mysql::ATTR_SSL_CERT') ? Pdo\Mysql::ATTR_SSL_CERT : PDO::MYSQL_ATTR_SSL_CERT) => env('DB_MYSQL_ATTR_SSL_CERT'),
(defined('Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT') ? Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT) => env('DB_MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', true),
PDO::ATTR_PERSISTENT => env('DB_ATTR_PERSISTENT'),
]) : [],
], function($value) { return $value !== null; }) : [],
],
'testing' => [
'driver' => 'mysql',
//'url' => env('DB_TEST_DATABASE_URL'),
'host' => '127.0.0.1',
'database' => 'freescout-test',
'host' => env('DB_TEST_HOST', '127.0.0.1'),
'database' => env('DB_TEST_DATABASE', 'freescout-test'),
'username' => env('DB_TEST_USERNAME', 'freescout-test'),
'password' => env('DB_TEST_PASSWORD', 'freescout-test'),
//'port' => env('DB_TEST_PORT', '3306'),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
<?php
/**
* Validates a font family list according to CSS spec
*/
class HTMLPurifier_AttrDef_CSS_FontFamily extends HTMLPurifier_AttrDef
{
protected $mask = null;
public function __construct()
{
$this->mask = '_- ';
for ($c = 'a'; $c <= 'z'; (function_exists('str_increment') ? $c = str_increment($c) : $c++)) {
$this->mask .= $c;
}
for ($c = 'A'; $c <= 'Z'; (function_exists('str_increment') ? $c = str_increment($c) : $c++)) {
$this->mask .= $c;
}
for ($c = '0'; $c <= '9'; $c++) {
$this->mask .= $c;
} // cast-y, but should be fine
// special bytes used by UTF-8
for ($i = 0x80; $i <= 0xFF; $i++) {
// We don't bother excluding invalid bytes in this range,
// because the our restriction of well-formed UTF-8 will
// prevent these from ever occurring.
$this->mask .= chr($i);
}
/*
PHP's internal strcspn implementation is
O(length of string * length of mask), making it inefficient
for large masks. However, it's still faster than
preg_match 8)
for (p = s1;;) {
spanp = s2;
do {
if (*spanp == c || p == s1_end) {
return p - s1;
}
} while (spanp++ < (s2_end - 1));
c = *++p;
}
*/
// possible optimization: invert the mask.
}
/**
* @param string $string
* @param HTMLPurifier_Config $config
* @param HTMLPurifier_Context $context
* @return bool|string
*/
public function validate($string, $config, $context)
{
static $generic_names = array(
'serif' => true,
'sans-serif' => true,
'monospace' => true,
'fantasy' => true,
'cursive' => true
);
$allowed_fonts = $config->get('CSS.AllowedFonts');
// assume that no font names contain commas in them
$fonts = explode(',', $string);
$final = '';
foreach ($fonts as $font) {
$font = trim($font);
if ($font === '') {
continue;
}
// match a generic name
if (isset($generic_names[$font])) {
if ($allowed_fonts === null || isset($allowed_fonts[$font])) {
$final .= $font . ', ';
}
continue;
}
// match a quoted name
if ($font[0] === '"' || $font[0] === "'") {
$length = strlen($font);
if ($length <= 2) {
continue;
}
$quote = $font[0];
if ($font[$length - 1] !== $quote) {
continue;
}
$font = substr($font, 1, $length - 2);
}
$font = $this->expandCSSEscape($font);
// $font is a pure representation of the font name
if ($allowed_fonts !== null && !isset($allowed_fonts[$font])) {
continue;
}
if (ctype_alnum($font) && $font !== '') {
// very simple font, allow it in unharmed
$final .= $font . ', ';
continue;
}
// bugger out on whitespace. form feed (0C) really
// shouldn't show up regardless
$font = str_replace(array("\n", "\t", "\r", "\x0C"), ' ', $font);
// Here, there are various classes of characters which need
// to be treated differently:
// - Alphanumeric characters are essentially safe. We
// handled these above.
// - Spaces require quoting, though most parsers will do
// the right thing if there aren't any characters that
// can be misinterpreted
// - Dashes rarely occur, but they fairly unproblematic
// for parsing/rendering purposes.
// The above characters cover the majority of Western font
// names.
// - Arbitrary Unicode characters not in ASCII. Because
// most parsers give little thought to Unicode, treatment
// of these codepoints is basically uniform, even for
// punctuation-like codepoints. These characters can
// show up in non-Western pages and are supported by most
// major browsers, for example: "MS 明朝" is a
// legitimate font-name
// <http://ja.wikipedia.org/wiki/MS_明朝>. See
// the CSS3 spec for more examples:
// <http://www.w3.org/TR/2011/WD-css3-fonts-20110324/localizedfamilynames.png>
// You can see live samples of these on the Internet:
// <http://www.google.co.jp/search?q=font-family+MS+明朝|ゴシック>
// However, most of these fonts have ASCII equivalents:
// for example, 'MS Mincho', and it's considered
// professional to use ASCII font names instead of
// Unicode font names. Thanks Takeshi Terada for
// providing this information.
// The following characters, to my knowledge, have not been
// used to name font names.
// - Single quote. While theoretically you might find a
// font name that has a single quote in its name (serving
// as an apostrophe, e.g. Dave's Scribble), I haven't
// been able to find any actual examples of this.
// Internet Explorer's cssText translation (which I
// believe is invoked by innerHTML) normalizes any
// quoting to single quotes, and fails to escape single
// quotes. (Note that this is not IE's behavior for all
// CSS properties, just some sort of special casing for
// font-family). So a single quote *cannot* be used
// safely in the font-family context if there will be an
// innerHTML/cssText translation. Note that Firefox 3.x
// does this too.
// - Double quote. In IE, these get normalized to
// single-quotes, no matter what the encoding. (Fun
// fact, in IE8, the 'content' CSS property gained
// support, where they special cased to preserve encoded
// double quotes, but still translate unadorned double
// quotes into single quotes.) So, because their
// fixpoint behavior is identical to single quotes, they
// cannot be allowed either. Firefox 3.x displays
// single-quote style behavior.
// - Backslashes are reduced by one (so \\ -> \) every
// iteration, so they cannot be used safely. This shows
// up in IE7, IE8 and FF3
// - Semicolons, commas and backticks are handled properly.
// - The rest of the ASCII punctuation is handled properly.
// We haven't checked what browsers do to unadorned
// versions, but this is not important as long as the
// browser doesn't /remove/ surrounding quotes (as IE does
// for HTML).
//
// With these results in hand, we conclude that there are
// various levels of safety:
// - Paranoid: alphanumeric, spaces and dashes(?)
// - International: Paranoid + non-ASCII Unicode
// - Edgy: Everything except quotes, backslashes
// - NoJS: Standards compliance, e.g. sod IE. Note that
// with some judicious character escaping (since certain
// types of escaping doesn't work) this is theoretically
// OK as long as innerHTML/cssText is not called.
// We believe that international is a reasonable default
// (that we will implement now), and once we do more
// extensive research, we may feel comfortable with dropping
// it down to edgy.
// Edgy: alphanumeric, spaces, dashes, underscores and Unicode. Use of
// str(c)spn assumes that the string was already well formed
// Unicode (which of course it is).
if (strspn($font, $this->mask) !== strlen($font)) {
continue;
}
// Historical:
// In the absence of innerHTML/cssText, these ugly
// transforms don't pose a security risk (as \\ and \"
// might--these escapes are not supported by most browsers).
// We could try to be clever and use single-quote wrapping
// when there is a double quote present, but I have choosen
// not to implement that. (NOTE: you can reduce the amount
// of escapes by one depending on what quoting style you use)
// $font = str_replace('\\', '\\5C ', $font);
// $font = str_replace('"', '\\22 ', $font);
// $font = str_replace("'", '\\27 ', $font);
// font possibly with spaces, requires quoting
$final .= "'$font', ";
}
$final = rtrim($final, ', ');
if ($final === '') {
return false;
}
return $final;
}
}
// vim: et sw=4 sts=4

View File

@@ -0,0 +1,58 @@
<?php
namespace Illuminate\Session;
use SessionHandlerInterface;
class NullSessionHandler implements SessionHandlerInterface
{
/**
* {@inheritdoc}
*/
public function open($savePath, $sessionName): bool
{
return true;
}
/**
* {@inheritdoc}
*/
public function close(): bool
{
return true;
}
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function read($sessionId)
{
return '';
}
/**
* {@inheritdoc}
*/
public function write($sessionId, $data): bool
{
return true;
}
/**
* {@inheritdoc}
*/
public function destroy($sessionId): bool
{
return true;
}
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function gc($lifetime)
{
return true;
}
}

View File

@@ -0,0 +1,384 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser;
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
use Symfony\Component\CssSelector\Node;
use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer;
/**
* CSS selector parser.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Parser implements ParserInterface
{
private $tokenizer;
public function __construct(?Tokenizer $tokenizer = null)
{
$this->tokenizer = $tokenizer ?: new Tokenizer();
}
/**
* {@inheritdoc}
*/
public function parse($source)
{
$reader = new Reader($source);
$stream = $this->tokenizer->tokenize($reader);
return $this->parseSelectorList($stream);
}
/**
* Parses the arguments for ":nth-child()" and friends.
*
* @param Token[] $tokens
*
* @return array
*
* @throws SyntaxErrorException
*/
public static function parseSeries(array $tokens)
{
foreach ($tokens as $token) {
if ($token->isString()) {
throw SyntaxErrorException::stringAsFunctionArgument();
}
}
$joined = trim(implode('', array_map(function (Token $token) {
return $token->getValue();
}, $tokens)));
$int = function ($string) {
if (!is_numeric($string)) {
throw SyntaxErrorException::stringAsFunctionArgument();
}
return (int) $string;
};
switch (true) {
case 'odd' === $joined:
return array(2, 1);
case 'even' === $joined:
return array(2, 0);
case 'n' === $joined:
return array(1, 0);
case false === strpos($joined, 'n'):
return array(0, $int($joined));
}
$split = explode('n', $joined);
$first = isset($split[0]) ? $split[0] : null;
return array(
$first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1,
isset($split[1]) && $split[1] ? $int($split[1]) : 0,
);
}
/**
* Parses selector nodes.
*
* @return array
*/
private function parseSelectorList(TokenStream $stream)
{
$stream->skipWhitespace();
$selectors = array();
while (true) {
$selectors[] = $this->parserSelectorNode($stream);
if ($stream->getPeek()->isDelimiter(array(','))) {
$stream->getNext();
$stream->skipWhitespace();
} else {
break;
}
}
return $selectors;
}
/**
* Parses next selector or combined node.
*
* @return Node\SelectorNode
*
* @throws SyntaxErrorException
*/
private function parserSelectorNode(TokenStream $stream)
{
list($result, $pseudoElement) = $this->parseSimpleSelector($stream);
while (true) {
$stream->skipWhitespace();
$peek = $stream->getPeek();
if ($peek->isFileEnd() || $peek->isDelimiter(array(','))) {
break;
}
if (null !== $pseudoElement) {
throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
}
if ($peek->isDelimiter(array('+', '>', '~'))) {
$combinator = $stream->getNext()->getValue();
$stream->skipWhitespace();
} else {
$combinator = ' ';
}
list($nextSelector, $pseudoElement) = $this->parseSimpleSelector($stream);
$result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector);
}
return new Node\SelectorNode($result, $pseudoElement);
}
/**
* Parses next simple node (hash, class, pseudo, negation).
*
* @param TokenStream $stream
* @param bool $insideNegation
*
* @return array
*
* @throws SyntaxErrorException
*/
private function parseSimpleSelector(TokenStream $stream, $insideNegation = false)
{
$stream->skipWhitespace();
$selectorStart = \count($stream->getUsed());
$result = $this->parseElementNode($stream);
$pseudoElement = null;
while (true) {
$peek = $stream->getPeek();
if ($peek->isWhitespace()
|| $peek->isFileEnd()
|| $peek->isDelimiter(array(',', '+', '>', '~'))
|| ($insideNegation && $peek->isDelimiter(array(')')))
) {
break;
}
if (null !== $pseudoElement) {
throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
}
if ($peek->isHash()) {
$result = new Node\HashNode($result, $stream->getNext()->getValue());
} elseif ($peek->isDelimiter(array('.'))) {
$stream->getNext();
$result = new Node\ClassNode($result, $stream->getNextIdentifier());
} elseif ($peek->isDelimiter(array('['))) {
$stream->getNext();
$result = $this->parseAttributeNode($result, $stream);
} elseif ($peek->isDelimiter(array(':'))) {
$stream->getNext();
if ($stream->getPeek()->isDelimiter(array(':'))) {
$stream->getNext();
$pseudoElement = $stream->getNextIdentifier();
continue;
}
$identifier = $stream->getNextIdentifier();
if (\in_array(strtolower($identifier), array('first-line', 'first-letter', 'before', 'after'))) {
// Special case: CSS 2.1 pseudo-elements can have a single ':'.
// Any new pseudo-element must have two.
$pseudoElement = $identifier;
continue;
}
if (!$stream->getPeek()->isDelimiter(array('('))) {
$result = new Node\PseudoNode($result, $identifier);
continue;
}
$stream->getNext();
$stream->skipWhitespace();
if ('not' === strtolower($identifier)) {
if ($insideNegation) {
throw SyntaxErrorException::nestedNot();
}
list($argument, $argumentPseudoElement) = $this->parseSimpleSelector($stream, true);
$next = $stream->getNext();
if (null !== $argumentPseudoElement) {
throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()');
}
if (!$next->isDelimiter(array(')'))) {
throw SyntaxErrorException::unexpectedToken('")"', $next);
}
$result = new Node\NegationNode($result, $argument);
} else {
$arguments = array();
$next = null;
while (true) {
$stream->skipWhitespace();
$next = $stream->getNext();
if ($next->isIdentifier()
|| $next->isString()
|| $next->isNumber()
|| $next->isDelimiter(array('+', '-'))
) {
$arguments[] = $next;
} elseif ($next->isDelimiter(array(')'))) {
break;
} else {
throw SyntaxErrorException::unexpectedToken('an argument', $next);
}
}
if (empty($arguments)) {
throw SyntaxErrorException::unexpectedToken('at least one argument', $next);
}
$result = new Node\FunctionNode($result, $identifier, $arguments);
}
} else {
throw SyntaxErrorException::unexpectedToken('selector', $peek);
}
}
if (\count($stream->getUsed()) === $selectorStart) {
throw SyntaxErrorException::unexpectedToken('selector', $stream->getPeek());
}
return array($result, $pseudoElement);
}
/**
* Parses next element node.
*
* @return Node\ElementNode
*/
private function parseElementNode(TokenStream $stream)
{
$peek = $stream->getPeek();
if ($peek->isIdentifier() || $peek->isDelimiter(array('*'))) {
if ($peek->isIdentifier()) {
$namespace = $stream->getNext()->getValue();
} else {
$stream->getNext();
$namespace = null;
}
if ($stream->getPeek()->isDelimiter(array('|'))) {
$stream->getNext();
$element = $stream->getNextIdentifierOrStar();
} else {
$element = $namespace;
$namespace = null;
}
} else {
$element = $namespace = null;
}
return new Node\ElementNode($namespace, $element);
}
/**
* Parses next attribute node.
*
* @return Node\AttributeNode
*
* @throws SyntaxErrorException
*/
private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream)
{
$stream->skipWhitespace();
$attribute = $stream->getNextIdentifierOrStar();
if (null === $attribute && !$stream->getPeek()->isDelimiter(array('|'))) {
throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek());
}
if ($stream->getPeek()->isDelimiter(array('|'))) {
$stream->getNext();
if ($stream->getPeek()->isDelimiter(array('='))) {
$namespace = null;
$stream->getNext();
$operator = '|=';
} else {
$namespace = $attribute;
$attribute = $stream->getNextIdentifier();
$operator = null;
}
} else {
$namespace = $operator = null;
}
if (null === $operator) {
$stream->skipWhitespace();
$next = $stream->getNext();
if ($next->isDelimiter(array(']'))) {
return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null);
} elseif ($next->isDelimiter(array('='))) {
$operator = '=';
} elseif ($next->isDelimiter(array('^', '$', '*', '~', '|', '!'))
&& $stream->getPeek()->isDelimiter(array('='))
) {
$operator = $next->getValue().'=';
$stream->getNext();
} else {
throw SyntaxErrorException::unexpectedToken('operator', $next);
}
}
$stream->skipWhitespace();
$value = $stream->getNext();
if ($value->isNumber()) {
// if the value is a number, it's casted into a string
$value = new Token(Token::TYPE_STRING, (string) $value->getValue(), $value->getPosition());
}
if (!($value->isIdentifier() || $value->isString())) {
throw SyntaxErrorException::unexpectedToken('string or identifier', $value);
}
$stream->skipWhitespace();
$next = $stream->getNext();
if (!$next->isDelimiter(array(']'))) {
throw SyntaxErrorException::unexpectedToken('"]"', $next);
}
return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue());
}
}

View File

@@ -0,0 +1,267 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath;
use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
use Symfony\Component\CssSelector\Node\FunctionNode;
use Symfony\Component\CssSelector\Node\NodeInterface;
use Symfony\Component\CssSelector\Node\SelectorNode;
use Symfony\Component\CssSelector\Parser\Parser;
use Symfony\Component\CssSelector\Parser\ParserInterface;
/**
* XPath expression translator interface.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Translator implements TranslatorInterface
{
private $mainParser;
/**
* @var ParserInterface[]
*/
private $shortcutParsers = array();
/**
* @var Extension\ExtensionInterface[]
*/
private $extensions = array();
private $nodeTranslators = array();
private $combinationTranslators = array();
private $functionTranslators = array();
private $pseudoClassTranslators = array();
private $attributeMatchingTranslators = array();
public function __construct(?ParserInterface $parser = null)
{
$this->mainParser = $parser ?: new Parser();
$this
->registerExtension(new Extension\NodeExtension())
->registerExtension(new Extension\CombinationExtension())
->registerExtension(new Extension\FunctionExtension())
->registerExtension(new Extension\PseudoClassExtension())
->registerExtension(new Extension\AttributeMatchingExtension())
;
}
/**
* @param string $element
*
* @return string
*/
public static function getXpathLiteral($element)
{
if (false === strpos($element, "'")) {
return "'".$element."'";
}
if (false === strpos($element, '"')) {
return '"'.$element.'"';
}
$string = $element;
$parts = array();
while (true) {
if (false !== $pos = strpos($string, "'")) {
$parts[] = sprintf("'%s'", substr($string, 0, $pos));
$parts[] = "\"'\"";
$string = substr($string, $pos + 1);
} else {
$parts[] = "'$string'";
break;
}
}
return sprintf('concat(%s)', implode(', ', $parts));
}
/**
* {@inheritdoc}
*/
public function cssToXPath($cssExpr, $prefix = 'descendant-or-self::')
{
$selectors = $this->parseSelectors($cssExpr);
/** @var SelectorNode $selector */
foreach ($selectors as $index => $selector) {
if (null !== $selector->getPseudoElement()) {
throw new ExpressionErrorException('Pseudo-elements are not supported.');
}
$selectors[$index] = $this->selectorToXPath($selector, $prefix);
}
return implode(' | ', $selectors);
}
/**
* {@inheritdoc}
*/
public function selectorToXPath(SelectorNode $selector, $prefix = 'descendant-or-self::')
{
return ($prefix ?: '').$this->nodeToXPath($selector);
}
/**
* Registers an extension.
*
* @return $this
*/
public function registerExtension(Extension\ExtensionInterface $extension)
{
$this->extensions[$extension->getName()] = $extension;
$this->nodeTranslators = array_merge($this->nodeTranslators, $extension->getNodeTranslators());
$this->combinationTranslators = array_merge($this->combinationTranslators, $extension->getCombinationTranslators());
$this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators());
$this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators());
$this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators());
return $this;
}
/**
* @param string $name
*
* @return Extension\ExtensionInterface
*
* @throws ExpressionErrorException
*/
public function getExtension($name)
{
if (!isset($this->extensions[$name])) {
throw new ExpressionErrorException(sprintf('Extension "%s" not registered.', $name));
}
return $this->extensions[$name];
}
/**
* Registers a shortcut parser.
*
* @return $this
*/
public function registerParserShortcut(ParserInterface $shortcut)
{
$this->shortcutParsers[] = $shortcut;
return $this;
}
/**
* @return XPathExpr
*
* @throws ExpressionErrorException
*/
public function nodeToXPath(NodeInterface $node)
{
if (!isset($this->nodeTranslators[$node->getNodeName()])) {
throw new ExpressionErrorException(sprintf('Node "%s" not supported.', $node->getNodeName()));
}
return \call_user_func($this->nodeTranslators[$node->getNodeName()], $node, $this);
}
/**
* @param string $combiner
* @param NodeInterface $xpath
* @param NodeInterface $combinedXpath
*
* @return XPathExpr
*
* @throws ExpressionErrorException
*/
public function addCombination($combiner, NodeInterface $xpath, NodeInterface $combinedXpath)
{
if (!isset($this->combinationTranslators[$combiner])) {
throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner));
}
return \call_user_func($this->combinationTranslators[$combiner], $this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath));
}
/**
* @return XPathExpr
*
* @throws ExpressionErrorException
*/
public function addFunction(XPathExpr $xpath, FunctionNode $function)
{
if (!isset($this->functionTranslators[$function->getName()])) {
throw new ExpressionErrorException(sprintf('Function "%s" not supported.', $function->getName()));
}
return \call_user_func($this->functionTranslators[$function->getName()], $xpath, $function);
}
/**
* @param XPathExpr $xpath
* @param string $pseudoClass
*
* @return XPathExpr
*
* @throws ExpressionErrorException
*/
public function addPseudoClass(XPathExpr $xpath, $pseudoClass)
{
if (!isset($this->pseudoClassTranslators[$pseudoClass])) {
throw new ExpressionErrorException(sprintf('Pseudo-class "%s" not supported.', $pseudoClass));
}
return \call_user_func($this->pseudoClassTranslators[$pseudoClass], $xpath);
}
/**
* @param XPathExpr $xpath
* @param string $operator
* @param string $attribute
* @param string $value
*
* @return XPathExpr
*
* @throws ExpressionErrorException
*/
public function addAttributeMatching(XPathExpr $xpath, $operator, $attribute, $value)
{
if (!isset($this->attributeMatchingTranslators[$operator])) {
throw new ExpressionErrorException(sprintf('Attribute matcher operator "%s" not supported.', $operator));
}
return \call_user_func($this->attributeMatchingTranslators[$operator], $xpath, $attribute, $value);
}
/**
* @param string $css
*
* @return SelectorNode[]
*/
private function parseSelectors($css)
{
foreach ($this->shortcutParsers as $shortcut) {
$tokens = $shortcut->parse($css);
if (!empty($tokens)) {
return $tokens;
}
}
return $this->mainParser->parse($css);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace TijsVerkoyen\CssToInlineStyles\Css\Property;
use Symfony\Component\CssSelector\Node\Specificity;
class Processor
{
/**
* Split a string into separate properties
*
* @param string $propertiesString
*
* @return string[]
*/
public function splitIntoSeparateProperties($propertiesString)
{
$propertiesString = $this->cleanup($propertiesString);
$properties = (array) explode(';', $propertiesString);
$keysToRemove = array();
$numberOfProperties = count($properties);
for ($i = 0; $i < $numberOfProperties; $i++) {
$properties[$i] = trim($properties[$i]);
// if the new property begins with base64 it is part of the current property
if (isset($properties[$i + 1]) && strpos(trim($properties[$i + 1]), 'base64,') === 0) {
$properties[$i] .= ';' . trim($properties[$i + 1]);
$keysToRemove[] = $i + 1;
}
}
if (!empty($keysToRemove)) {
foreach ($keysToRemove as $key) {
unset($properties[$key]);
}
}
return array_values($properties);
}
/**
* @param string $string
*
* @return string
*/
private function cleanup($string)
{
$string = str_replace(array("\r", "\n"), '', $string);
$string = str_replace(array("\t"), ' ', $string);
$string = str_replace('"', '\'', $string);
$string = preg_replace('|/\*.*?\*/|', '', $string);
$string = preg_replace('/\s\s+/', ' ', $string);
$string = trim($string);
$string = rtrim($string, ';');
return $string;
}
/**
* Converts a property-string into an object
*
* @param string $property
*
* @return Property|null
*/
public function convertToObject($property, ?Specificity $specificity = null)
{
if (strpos($property, ':') === false) {
return null;
}
list($name, $value) = explode(':', $property, 2);
$name = trim($name);
$value = trim($value);
if ($value === '') {
return null;
}
return new Property($name, $value, $specificity);
}
/**
* Converts an array of property-strings into objects
*
* @param string[] $properties
*
* @return Property[]
*/
public function convertArrayToObjects(array $properties, ?Specificity $specificity = null)
{
$objects = array();
foreach ($properties as $property) {
$object = $this->convertToObject($property, $specificity);
if ($object === null) {
continue;
}
$objects[] = $object;
}
return $objects;
}
/**
* Build the property-string for multiple properties
*
* @param Property[] $properties
*
* @return string
*/
public function buildPropertiesString(array $properties)
{
$chunks = array();
foreach ($properties as $property) {
$chunks[] = $property->toString();
}
return implode(' ', $chunks);
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace TijsVerkoyen\CssToInlineStyles\Css\Property;
use Symfony\Component\CssSelector\Node\Specificity;
final class Property
{
/**
* @var string
*/
private $name;
/**
* @var string
*/
private $value;
/**
* @var Specificity
*/
private $originalSpecificity;
/**
* Property constructor.
* @param string $name
* @param string $value
* @param Specificity|null $specificity
*/
public function __construct($name, $value, ?Specificity $specificity = null)
{
$this->name = $name;
$this->value = $value;
$this->originalSpecificity = $specificity;
}
/**
* Get name
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Get value
*
* @return string
*/
public function getValue()
{
return $this->value;
}
/**
* Get originalSpecificity
*
* @return Specificity
*/
public function getOriginalSpecificity()
{
return $this->originalSpecificity;
}
/**
* Is this property important?
*
* @return bool
*/
public function isImportant()
{
return (stripos($this->value, '!important') !== false);
}
/**
* Get the textual representation of the property
*
* @return string
*/
public function toString()
{
return sprintf(
'%1$s: %2$s;',
$this->name,
$this->value
);
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace TijsVerkoyen\CssToInlineStyles;
use Symfony\Component\CssSelector\CssSelector;
use Symfony\Component\CssSelector\CssSelectorConverter;
use Symfony\Component\CssSelector\Exception\ExceptionInterface;
use TijsVerkoyen\CssToInlineStyles\Css\Processor;
use TijsVerkoyen\CssToInlineStyles\Css\Property\Processor as PropertyProcessor;
use TijsVerkoyen\CssToInlineStyles\Css\Rule\Processor as RuleProcessor;
class CssToInlineStyles
{
private $cssConverter;
public function __construct()
{
if (class_exists('Symfony\Component\CssSelector\CssSelectorConverter')) {
$this->cssConverter = new CssSelectorConverter();
}
}
/**
* Will inline the $css into the given $html
*
* Remark: if the html contains <style>-tags those will be used, the rules
* in $css will be appended.
*
* @param string $html
* @param string $css
*
* @return string
*/
public function convert($html, $css = null)
{
$document = $this->createDomDocumentFromHtml($html);
$processor = new Processor();
// get all styles from the style-tags
$rules = $processor->getRules(
$processor->getCssFromStyleTags($html)
);
if ($css !== null) {
$rules = $processor->getRules($css, $rules);
}
$document = $this->inline($document, $rules);
return $this->getHtmlFromDocument($document);
}
/**
* Inline the given properties on an given DOMElement
*
* @param \DOMElement $element
* @param Css\Property\Property[] $properties
*
* @return \DOMElement
*/
public function inlineCssOnElement(\DOMElement $element, array $properties)
{
if (empty($properties)) {
return $element;
}
$cssProperties = array();
$inlineProperties = array();
foreach ($this->getInlineStyles($element) as $property) {
$inlineProperties[$property->getName()] = $property;
}
foreach ($properties as $property) {
if (!isset($inlineProperties[$property->getName()])) {
$cssProperties[$property->getName()] = $property;
}
}
$rules = array();
foreach (array_merge($cssProperties, $inlineProperties) as $property) {
$rules[] = $property->toString();
}
$element->setAttribute('style', implode(' ', $rules));
return $element;
}
/**
* Get the current inline styles for a given DOMElement
*
* @param \DOMElement $element
*
* @return Css\Property\Property[]
*/
public function getInlineStyles(\DOMElement $element)
{
$processor = new PropertyProcessor();
return $processor->convertArrayToObjects(
$processor->splitIntoSeparateProperties(
$element->getAttribute('style')
)
);
}
/**
* @param string $html
*
* @return \DOMDocument
*/
protected function createDomDocumentFromHtml($html)
{
$document = new \DOMDocument('1.0', 'UTF-8');
$internalErrors = libxml_use_internal_errors(true);
$document->loadHTML(mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8'));
libxml_use_internal_errors($internalErrors);
$document->formatOutput = true;
return $document;
}
/**
* @param \DOMDocument $document
*
* @return string
*/
protected function getHtmlFromDocument(\DOMDocument $document)
{
// retrieve the document element
// we do it this way to preserve the utf-8 encoding
$htmlElement = $document->documentElement;
$html = $document->saveHTML($htmlElement);
$html = trim($html);
// retrieve the doctype
$document->removeChild($htmlElement);
$doctype = $document->saveHTML();
$doctype = trim($doctype);
// if it is the html5 doctype convert it to lowercase
if ($doctype === '<!DOCTYPE html>') {
$doctype = strtolower($doctype);
}
return $doctype."\n".$html;
}
/**
* @param \DOMDocument $document
* @param Css\Rule\Rule[] $rules
*
* @return \DOMDocument
*/
protected function inline(\DOMDocument $document, array $rules)
{
if (empty($rules)) {
return $document;
}
$propertyStorage = new \SplObjectStorage();
$xPath = new \DOMXPath($document);
usort($rules, array(RuleProcessor::class, 'sortOnSpecificity'));
foreach ($rules as $rule) {
try {
if (null !== $this->cssConverter) {
$expression = $this->cssConverter->toXPath($rule->getSelector());
} else {
// Compatibility layer for Symfony 2.7 and older
$expression = CssSelector::toXPath($rule->getSelector());
}
} catch (ExceptionInterface $e) {
continue;
}
$elements = $xPath->query($expression);
if ($elements === false) {
continue;
}
foreach ($elements as $element) {
$propertyStorage[$element] = $this->calculatePropertiesToBeApplied(
$rule->getProperties(),
$propertyStorage->offsetExists($element) ? $propertyStorage[$element] : array()
);
}
}
foreach ($propertyStorage as $element) {
$this->inlineCssOnElement($element, $propertyStorage[$element]);
}
return $document;
}
/**
* Merge the CSS rules to determine the applied properties.
*
* @param Css\Property\Property[] $properties
* @param Css\Property\Property[] $cssProperties existing applied properties indexed by name
*
* @return Css\Property\Property[] updated properties, indexed by name
*/
private function calculatePropertiesToBeApplied(array $properties, array $cssProperties)
{
if (empty($properties)) {
return $cssProperties;
}
foreach ($properties as $property) {
if (isset($cssProperties[$property->getName()])) {
$existingProperty = $cssProperties[$property->getName()];
//skip check to overrule if existing property is important and current is not
if ($existingProperty->isImportant() && !$property->isImportant()) {
continue;
}
//overrule if current property is important and existing is not, else check specificity
$overrule = !$existingProperty->isImportant() && $property->isImportant();
if (!$overrule) {
$overrule = $existingProperty->getOriginalSpecificity()->compareTo($property->getOriginalSpecificity()) <= 0;
}
if ($overrule) {
unset($cssProperties[$property->getName()]);
$cssProperties[$property->getName()] = $property;
}
} else {
$cssProperties[$property->getName()] = $property;
}
}
return $cssProperties;
}
}

View File

@@ -811,6 +811,11 @@ class Header {
if (str_contains($value, ";") && str_contains($value, "=")) {
$_attributes = $this->read_attribute($value);
foreach($_attributes as $_key => $_value) {
// Without this the following will not be parsed properly with ";" on the end:
// Content-Type: text/html; charset="UTF-8";
if ($_key === "") {
continue;
}
if ($_value === "") {
// Remove existing value.
if (isset($this->attributes[$key])) {

View File

@@ -39,7 +39,6 @@
</rule>
<rule ref="Generic.Formatting.DisallowMultipleStatements"/>
<rule ref="Generic.Formatting.SpaceAfterCast"/>
<rule ref="Generic.Functions.CallTimePassByReference"/>
<rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
<rule ref="Generic.Functions.OpeningFunctionBraceBsdAllman"/>
<rule ref="Generic.Metrics.CyclomaticComplexity">
@@ -67,7 +66,6 @@
<property name="tabIndent" value="true"/>
</properties>
</rule>
<rule ref="MySource.PHP.EvalObjectFactory"/>
<rule ref="PSR1.Classes.ClassDeclaration"/>
<rule ref="PSR1.Files.SideEffects"/>
<rule ref="PSR2.Classes.ClassDeclaration"/>
@@ -114,7 +112,6 @@
<rule ref="Squiz.Scope.MethodScope"/>
<rule ref="Squiz.Scope.StaticThisUsage"/>
<rule ref="Squiz.WhiteSpace.CastSpacing"/>
<rule ref="Squiz.WhiteSpace.LanguageConstructSpacing"/>
<rule ref="Squiz.WhiteSpace.LogicalOperatorSpacing"/>
<rule ref="Squiz.WhiteSpace.ObjectOperatorSpacing">
<properties>
@@ -126,17 +123,12 @@
<property name="ignoreNewlines" value="true"/>
</properties>
</rule>
<rule ref="Squiz.WhiteSpace.PropertyLabelSpacing"/>
<rule ref="Squiz.WhiteSpace.ScopeClosingBrace"/>
<rule ref="Squiz.WhiteSpace.ScopeKeywordSpacing"/>
<rule ref="Squiz.WhiteSpace.SemicolonSpacing"/>
<rule ref="Zend.Files.ClosingTag"/>
<rule ref="PSR2.ControlStructures.ControlStructureSpacing">
<properties>
<property name="maxLineLength" value="1000" />
</properties>
</rule>
<rule ref="PSR2.ControlStructures.ControlStructureSpacing"/>
<exclude-pattern>*/.phpstorm.meta.php</exclude-pattern>
<exclude-pattern>*/_ide_helper.php</exclude-pattern>
@@ -150,6 +142,7 @@
<exclude-pattern>*/storage/*</exclude-pattern>
<exclude-pattern>*/docs/*</exclude-pattern>
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/overrides/*</exclude-pattern>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/config/*</exclude-pattern>
<exclude-pattern>*/public/index.php</exclude-pattern>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.2/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.2/phpunit.xsd">
<testsuites>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
@@ -16,9 +16,9 @@
<env name="QUEUE_DRIVER" value="sync"/>
<env name="APP_KEY" value="value_from_phpunit"/>
</php>
<source>
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
</source>
</coverage>
</phpunit>

View File

@@ -102,17 +102,42 @@
<div class="col-sm-6">
<input id="out_password" type="password" class="form-control input-sized @if ($out_oauth_enabled) disabled @endif" name="out_password" value="{{ old('out_password', $mailbox->outPasswordSafe()) }}" maxlength="255" @if ($mailbox->out_method == App\Mailbox::OUT_METHOD_SMTP) @endif autofocus {{-- This added to prevent autocomplete in Chrome --}}autocomplete="new-password" @if ($out_oauth_enabled) readonly @endif>
<p class="form-help">
<small @if ($mailbox->oauthGetParam('provider') == \MailHelper::OAUTH_PROVIDER_MICROSOFT && $out_oauth_enabled) class="text-success" @endif>Microsoft Exchange</small>
@if (!$mailbox->oauthEnabled())
@if ($mailbox->out_username && $mailbox->out_password && $mailbox->isOutUsernameOauth())
– <a href="{{ route('mailboxes.oauth', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'out']) }}" target="_blank">{{ __('Connect') }}</a>
@php
$active_oauth_provider = '';
if ($out_oauth_enabled) {
$active_oauth_provider = $mailbox->oauthGetParam('provider');
}
@endphp
{{-- Microsoft Exchange --}}
@if ($active_oauth_provider != \MailHelper::OAUTH_PROVIDER_GOOGLE)
<p class="form-help">
<small @if ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_MICROSOFT) && $out_oauth_enabled) class="text-success" @endif>Microsoft Exchange</small>
@if (!$mailbox->oauthEnabled())
@if ($mailbox->out_username && $mailbox->out_password && $mailbox->isOutUsernameOauth())
– <a href="{{ route('mailboxes.oauth', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'out']) }}" target="_blank">{{ __('Connect') }}</a>
@endif
@elseif ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_MICROSOFT) && $out_oauth_enabled)
– <a href="{{ route('mailboxes.oauth_disconnect', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'out']) }}">{{ __('Disconnect') }}</a>
@endif
@elseif ($mailbox->oauthGetParam('provider') == \MailHelper::OAUTH_PROVIDER_MICROSOFT && $out_oauth_enabled)
– <a href="{{ route('mailboxes.oauth_disconnect', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'out']) }}">{{ __('Disconnect') }}</a>
@endif
<small>(<a href="{{ config('app.freescout_repo') }}/wiki/Connect-FreeScout-to-Microsoft-365-Exchange-via-oAuth" target="_blank">{{ __('Help') }}</a>)</small>
</p>
<small>(<a href="{{ config('app.freescout_repo') }}/wiki/Connect-FreeScout-to-Microsoft-365-Exchange-via-oAuth" target="_blank">{{ __('Help') }}</a>)</small>
</p>
@endif
{{-- Google Workspace --}}
@if ($active_oauth_provider != \MailHelper::OAUTH_PROVIDER_MICROSOFT)
<p class="form-help">
<small @if ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_GOOGLE) && $out_oauth_enabled) class="text-success" @endif>Google Workspace</small>
@if (!$mailbox->oauthEnabled())
@if ($mailbox->out_username && $mailbox->out_password && $mailbox->isOutUsernameOauth())
– <a href="{{ route('mailboxes.oauth', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_GOOGLE, 'in_out' => 'out']) }}" target="_blank">{{ __('Connect') }}</a>
@endif
@elseif ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_GOOGLE) && $out_oauth_enabled)
– <a href="{{ route('mailboxes.oauth_disconnect', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_GOOGLE, 'in_out' => 'out']) }}">{{ __('Disconnect') }}</a>
@endif
<small>(<a href="{{ config('app.freescout_repo') }}/wiki/Connect-FreeScout-to-Google-Workspace" target="_blank">{{ __('Help') }}</a>)</small>
</p>
@endif
</div>
</div>
<div class="form-group{{ $errors->has('out_encryption') ? ' has-error' : '' }}">

View File

@@ -107,17 +107,42 @@
<div class="col-sm-6">
<input id="in_password" type="password" class="form-control input-sized @if ($in_oauth_enabled) disabled @endif" name="in_password" value="{{ old('in_password', $mailbox->inPasswordSafe()) }}" maxlength="255" {{-- This added to prevent autocomplete in Chrome --}}autocomplete="new-password" @if ($in_oauth_enabled) readonly @endif>
<p class="form-help">
<small @if ($mailbox->oauthGetParam('provider') == \MailHelper::OAUTH_PROVIDER_MICROSOFT) class="text-success" @endif>Microsoft Exchange</small>
@if (!$mailbox->oauthEnabled())
@if ($mailbox->in_username && $mailbox->in_password && $mailbox->isInUsernameOauth())
– <a href="{{ route('mailboxes.oauth', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'in']) }}" target="_blank">{{ __('Connect') }}</a>
@php
$active_oauth_provider = '';
if ($in_oauth_enabled) {
$active_oauth_provider = $mailbox->oauthGetParam('provider');
}
@endphp
{{-- Microsoft Exchange --}}
@if ($active_oauth_provider != \MailHelper::OAUTH_PROVIDER_GOOGLE)
<p class="form-help">
<small @if ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_MICROSOFT)) class="text-success" @endif>Microsoft Exchange</small>
@if (!$mailbox->oauthEnabled())
@if ($mailbox->in_username && $mailbox->in_password && $mailbox->isInUsernameOauth())
– <a href="{{ route('mailboxes.oauth', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'in']) }}" target="_blank">{{ __('Connect') }}</a>
@endif
@elseif ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_MICROSOFT) && $in_oauth_enabled)
– <a href="{{ route('mailboxes.oauth_disconnect', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'in']) }}">{{ __('Disconnect') }}</a>
@endif
@elseif ($mailbox->oauthGetParam('provider') == \MailHelper::OAUTH_PROVIDER_MICROSOFT && $in_oauth_enabled)
– <a href="{{ route('mailboxes.oauth_disconnect', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_MICROSOFT, 'in_out' => 'in']) }}">{{ __('Disconnect') }}</a>
@endif
<small>(<a href="{{ config('app.freescout_repo') }}/wiki/Connect-FreeScout-to-Microsoft-365-Exchange-via-oAuth" target="_blank">{{ __('Help') }}</a>)</small>
</p>
<small>(<a href="{{ config('app.freescout_repo') }}/wiki/Connect-FreeScout-to-Microsoft-365-Exchange-via-oAuth" target="_blank">{{ __('Help') }}</a>)</small>
</p>
@endif
{{-- Google Workspace --}}
@if ($active_oauth_provider != \MailHelper::OAUTH_PROVIDER_MICROSOFT)
<p class="form-help">
<small @if ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_GOOGLE)) class="text-success" @endif>Google Workspace</small>
@if (!$mailbox->oauthEnabled())
@if ($mailbox->in_username && $mailbox->in_password && $mailbox->isInUsernameOauth())
– <a href="{{ route('mailboxes.oauth', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_GOOGLE, 'in_out' => 'in']) }}" target="_blank">{{ __('Connect') }}</a>
@endif
@elseif ($mailbox->isOauthProvider(\MailHelper::OAUTH_PROVIDER_GOOGLE) && $in_oauth_enabled)
– <a href="{{ route('mailboxes.oauth_disconnect', ['id' => $mailbox->id, 'provider' => \MailHelper::OAUTH_PROVIDER_GOOGLE, 'in_out' => 'in']) }}">{{ __('Disconnect') }}</a>
@endif
<small>(<a href="{{ config('app.freescout_repo') }}/wiki/Connect-FreeScout-to-Google-Workspace" target="_blank">{{ __('Help') }}</a>)</small>
</p>
@endif
{{--@include('partials/field_error', ['field'=>'in_password'])--}}
</div>
</div>

View File

@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Conversation;
use App\Customer;
use App\Email;
use App\Mailbox;
use App\Thread;
use App\User;
//use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ConversationChangeCustomerTest extends TestCase
{
// This cleans existing tables.
//use RefreshDatabase;
private $admin;
private $unprivUser;
private $mailbox;
private $conversation;
private $originalCustomer;
private $attackerCustomer;
protected function setUp(): void
{
parent::setUp();
// Without it csrf_token() returns empty string.
\Session::start();
// Create admin user
$this->admin = factory(User::class)->create([
'role' => User::ROLE_ADMIN,
]);
// Create mailbox
$this->mailbox = factory(Mailbox::class)->create();
$this->mailbox->users()->sync([$this->admin->id]);
// Create unprivileged user with NO mailbox access
$this->unprivUser = factory(User::class)->create([
'role' => User::ROLE_USER,
]);
// Create customers
$this->originalCustomer = factory(Customer::class)->create([
'first_name' => 'Original',
'last_name' => 'Customer',
]);
$this->originalCustomer->syncEmails(['original.customer@example.org']);
$this->attackerCustomer = factory(Customer::class)->create([
'first_name' => 'Attacker',
'last_name' => 'Evil',
]);
$this->attackerCustomer->syncEmails(['attacker@example.org']);
// Create conversation belonging to original customer.
$this->conversation = factory(Conversation::class)->create([
'mailbox_id' => $this->mailbox->id,
'customer_id' => $this->originalCustomer->id,
'customer_email' => 'original.customer@example.org',
'status' => Conversation::STATUS_ACTIVE,
'state' => Conversation::STATE_PUBLISHED,
]);
}
/**
* Test that a user without mailbox access cannot change a conversation's customer.
* This is the regression test for the authorization bypass where changeCustomer()
* executed unconditionally even when permission checks set an error message.
*/
public function testUnauthorizedUserCannotChangeCustomer()
{
if (PHP_VERSION_ID < 80400) {
$this->assertEquals('1', '1');
return;
}
$response = $this->actingAs($this->unprivUser)
->post('/conversation/ajax', [
'action' => 'conversation_change_customer',
'conversation_id' => $this->conversation->id,
'customer_email' => 'attacker@example.org',
'_token' => csrf_token(),
], [
'X-Requested-With' => 'XMLHttpRequest',
]);
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('Not enough permissions', $json['msg']);
// The customer must NOT have changed
$this->conversation->refresh();
$this->assertEquals('original.customer@example.org', $this->conversation->customer_email);
$this->assertEquals($this->originalCustomer->id, $this->conversation->customer_id);
}
/**
* Test that an authorized admin user CAN change a conversation's customer.
* This ensures the fix does not break normal functionality.
*/
public function testAuthorizedUserCanChangeCustomer()
{
if (PHP_VERSION_ID < 80400) {
$this->assertEquals('1', '1');
return;
}
$response = $this->actingAs($this->admin)
->post('/conversation/ajax', [
'action' => 'conversation_change_customer',
'conversation_id' => $this->conversation->id,
'customer_email' => 'attacker@example.org',
'_token' => csrf_token(),
], [
'X-Requested-With' => 'XMLHttpRequest',
]);
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEmpty($json['msg']);
// The customer SHOULD have changed.
$this->conversation->refresh();
$this->assertEquals('attacker@example.org', $this->conversation->customer_email);
$this->assertEquals($this->attackerCustomer->id, $this->conversation->customer_id);
}
}

View File

@@ -11,7 +11,7 @@ Content-Type: text/plain; charset="UTF-8"
1
--000000000000ff062c062e516ec3
Content-Type: text/html; charset="UTF-8"
Content-Type: text/html; charset="UTF-8";
<div dir="ltr"><div>1</div></div>

View File

@@ -129,6 +129,13 @@ class WebklexTest extends FixtureWebklexMessage {
self::assertSame("------3f0eb27c226a6efc44713e1b8f40befd34d8d3c9199e2ad04dab9839cbbd3524", $message->header->getBoundary());
}
/**
* This test also makes sure that
* headers of multipart message are parsed properly
* when there is a ";" at the end:
*
* Content-Type: text/html; charset="UTF-8";
*/
public function testRegularEmail() {
$message = $this->getFixture("message-3.eml");