first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<?php namespace Common\Settings;
use Dotenv\Dotenv;
use Dotenv\Repository\RepositoryBuilder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class DotEnvEditor
{
public function __construct(protected string $fileName = '.env')
{
}
public function load(): array
{
$dotEnv = Dotenv::create(
RepositoryBuilder::createWithNoAdapters()->make(),
[base_path()],
$this->fileName,
);
$values = $dotEnv->load();
$lowercaseValues = [];
foreach ($values as $key => $value) {
if (strtolower($value) === 'null') {
$lowercaseValues[strtolower($key)] = null;
} elseif (strtolower($value) === 'false') {
$lowercaseValues[strtolower($key)] = false;
} elseif (strtolower($value) === 'true') {
$lowercaseValues[strtolower($key)] = true;
} elseif (preg_match('/\A([\'"])(.*)\1\z/', $value, $matches)) {
$lowercaseValues[strtolower($key)] = $matches[2];
} else {
$lowercaseValues[strtolower($key)] = $value;
}
}
return $lowercaseValues;
}
public function write(array|Collection $values = []): void
{
$content = file_get_contents(base_path($this->fileName));
foreach ($values as $key => $value) {
$value = $this->formatValue($value);
$key = strtoupper($key);
if (Str::contains($content, $key . '=')) {
preg_match("/($key=)(.*?)(\n|\Z)/msi", $content, $matches);
$content = str_replace(
$matches[1] . $matches[2],
$matches[1] . $value,
$content,
);
} else {
$content .= "\n$key=$value";
}
}
file_put_contents(base_path($this->fileName), $content);
}
/**
* Format specified value to be compatible with .env file
*/
private function formatValue(mixed $value = null): string
{
if ($value === 0 || $value === false) {
$value = 'false';
}
if ($value === 1 || $value === true) {
$value = 'true';
}
if (!$value) {
$value = 'null';
}
$value = trim($value);
// wrap string in quotes, if it contains whitespace or special characters
if (preg_match('/\s/', $value) || Str::contains($value, '#')) {
//replace double quotes with single quotes
$value = str_replace('"', "'", $value);
//wrap string in quotes
$value = '"' . $value . '"';
}
return $value;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Common\Settings\Events;
class SettingsSaved
{
/**
* @var array
*/
public $dbSettings;
/**
* @var array
*/
public $envSettings;
/**
* @param array $dbSettings
* @param array $envSettings
*/
public function __construct($dbSettings, $envSettings)
{
$this->dbSettings = $dbSettings;
$this->envSettings = $envSettings;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Common\Settings\Mail;
use Common\Auth\Oauth;
use Common\Core\BaseController;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Session;
use Laravel\Socialite\Facades\Socialite;
class ConnectGmailAccountController extends BaseController
{
public function connectGmail()
{
Session::flash(
Oauth::OAUTH_CALLBACK_HANDLER_KEY,
HandleConnectGmailOauthCallback::class,
);
$driver = Socialite::driver('google')
->scopes([
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
])
->with([
'access_type' => 'offline',
'prompt' => 'consent select_account',
]);
return $driver->redirect();
}
public static function getConnectedEmail(): ?string
{
if (!class_exists(GmailClient::class)) {
return null;
}
try {
$data = json_decode(File::get(GmailClient::tokenPath()), true);
return $data['email'] ?? null;
} catch (FileNotFoundException $e) {
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Common\Settings\Mail;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
class GmailApiMailTransport extends AbstractTransport
{
public function doSend(SentMessage $message): void
{
(new GmailClient())->sendEmail($message->toString());
}
public function __toString(): string
{
return 'gmailApi';
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Common\Settings\Mail;
use File;
use Google\Service\Gmail\Message;
use Google\Service\Gmail\WatchRequest;
use Google\Service\Gmail\WatchResponse;
use Google_Client;
use Google_Service_Gmail;
class GmailClient
{
private Google_Service_Gmail $gmail;
private Google_Client $googleClient;
public function __construct()
{
$this->buildGoogleClient();
}
public static function tokenPath(): string
{
return storage_path('app/tokens/gmail.json');
}
public static function tokenExists(): bool
{
return file_exists(self::tokenPath());
}
public function sendEmail(string $rawContent): Message
{
$encoded = strtr(base64_encode($rawContent), ['+' => '-', '/' => '_']);
$msg = new Message();
$msg->setRaw($encoded);
return $this->gmail->users_messages->send('me', $msg);
}
public function listHistory(int $historyId): array
{
$response = $this->gmail->users_history->listUsersHistory('me', [
'startHistoryId' => $historyId,
]);
$messageIds = collect($response['history'])
->map(function ($history) {
$msg = $history['messagesAdded'][0]['message'] ?? null;
$labels = $msg['labelIds'] ?? [];
if ($msg && !in_array('SENT', $labels)) {
return $msg['id'];
}
})
->filter();
if ($messageIds->isEmpty()) {
return [];
}
$this->googleClient->setUseBatch(true);
$batch = $this->gmail->createBatch();
$messageIds->each(function ($msgId) use ($batch) {
$request = $this->gmail->users_messages->get('me', $msgId, [
'format' => 'raw',
]);
$batch->add($request);
});
$this->googleClient->setUseBatch(false);
return array_values($batch->execute());
}
public function watch(): WatchResponse
{
$payload = new WatchRequest();
$payload->topicName = settings('incoming_email.gmail.topicName');
$payload->labelIds = ['UNREAD'];
$payload->labelFilterAction = 'include';
return $this->gmail->users->watch('me', $payload);
}
private function buildGoogleClient(): void
{
$this->googleClient = new Google_Client();
$this->googleClient->setClientId(config('services.google.client_id'));
$this->googleClient->setClientSecret(
config('services.google.client_secret'),
);
if (self::tokenExists()) {
$tokenJson = file_get_contents(self::tokenPath());
$accessToken = json_decode($tokenJson, true);
$this->googleClient->setAccessToken($accessToken);
}
if ($this->googleClient->isAccessTokenExpired()) {
$newToken = $this->googleClient->fetchAccessTokenWithRefreshToken(
$this->googleClient->getRefreshToken(),
);
$oldToken = json_decode(File::get(self::tokenPath()), true);
$mergedToken = array_merge($oldToken, $newToken);
File::put(self::tokenPath(), json_encode($mergedToken));
}
$this->gmail = new Google_Service_Gmail($this->googleClient);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Common\Settings\Mail;
use Common\Auth\Oauth;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Support\Facades\File;
use Laravel\Socialite\Facades\Socialite;
class HandleConnectGmailOauthCallback
{
public function execute(string $provider): ViewContract
{
$profile = Socialite::with('google')->user();
File::ensureDirectoryExists(dirname(GmailClient::tokenPath()));
File::put(
GmailClient::tokenPath(),
json_encode([
'access_token' => $profile->token,
'refresh_token' => $profile->refreshToken,
'created' => now()->timestamp,
'expires_in' => $profile->expiresIn,
'email' => $profile->email,
]),
);
if (settings('incoming_email.gmail.enabled')) {
(new GmailClient())->watch();
}
return (new Oauth())->getPopupResponse('SUCCESS', [
'profile' => $profile,
]);
}
}

75
common/Settings/Setting.php Executable file
View File

@@ -0,0 +1,75 @@
<?php namespace Common\Settings;
use Exception;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $table = 'settings';
protected $fillable = ['name', 'value'];
protected $casts = ['private' => 'bool'];
protected function value(): Attribute
{
return Attribute::make(
get: function ($value) {
if (
in_array($this->attributes['name'], Settings::$secretKeys)
) {
try {
$value = decrypt($value);
} catch (Exception $e) {
$value = '';
}
}
if (in_array($this->attributes['name'], Settings::$jsonKeys)) {
$value = json_decode($value, true);
}
if ($value === 'false') {
return false;
}
if ($value === 'true') {
return true;
}
if (ctype_digit($value)) {
return (int) $value;
}
return $value;
},
set: function ($value) {
$value = !is_null($value) ? $value : '';
if (
in_array($this->attributes['name'], Settings::$jsonKeys) &&
!is_string($value)
) {
$value = json_encode($value);
}
if ($value === true) {
$value = 'true';
} elseif ($value === false) {
$value = 'false';
}
$value = (string) $value;
if (
in_array($this->attributes['name'], Settings::$secretKeys)
) {
$value = encrypt($value);
}
return $value;
},
);
}
}

221
common/Settings/Settings.php Executable file
View File

@@ -0,0 +1,221 @@
<?php namespace Common\Settings;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class Settings
{
protected Collection $all;
/**
* Laravel config values that should be included with settings.
* (display name for client => laravel config key)
*/
protected array $configKeys = [
'billing.stripe_public_key' => 'services.stripe.key',
'billing.paypal.public_key' => 'services.paypal.client_id',
'site.demo' => 'common.site.demo',
'logging.sentry_public' => 'sentry.dsn',
'i18n.default_localization' => 'app.locale',
'billing.integrated' => 'common.site.billing_integrated',
'workspaces.integrated' => 'common.site.workspaces_integrated',
'notifications.integrated' => 'common.site.notifications_integrated',
'notif.subs.integrated' => 'common.site.notif_subs_integrated',
'api.integrated' => 'common.site.api_integrated',
'branding.site_name' => 'app.name',
'realtime.pusher_cluster' =>
'broadcasting.connections.pusher.options.cluster',
'realtime.pusher_key' => 'broadcasting.connections.pusher.key',
'site.hide_docs_buttons' => 'common.site.hide_docs_buttons',
'site.has_mobile_app' => 'common.site.has_mobile_app',
'uploads.public_driver' => 'common.site.public_disk_driver',
'uploads.uploads_driver' => 'common.site.uploads_disk_driver',
'uploads.disable_tus' => 'common.site.uploads_disable_tus',
];
/**
* Settings that are json encoded in database.
*/
public static array $jsonKeys = [
'menus',
'homepage.appearance',
'uploads.allowed_extensions',
'uploads.blocked_extensions',
'cookie_notice.button',
'registration.policies',
'artistPage.tabs',
'landing',
'hc.newTicket.appearance',
'incoming_email',
'title_page.sections',
'streaming.qualities',
'builder.template_categories',
'publish.default_credentials',
];
public static array $secretKeys = [
'recaptcha.secret_key',
'google_safe_browsing_key',
'incoming_email',
];
public function __construct()
{
$this->loadSettings();
}
public function all(bool $includeSecret = false): array
{
$all = $this->all;
// filter out secret (server-only) settings
if (!$includeSecret) {
$all = $all->filter(function ($value, $key) use ($includeSecret) {
return !in_array($key, self::$secretKeys);
});
}
return $all->toArray();
}
public function get(string|int $key, mixed $default = null): mixed
{
$value = Arr::get($this->all, $key) ?? $default;
return is_string($value) ? trim($value) : $value;
}
/**
* Get random setting value from fields that
* have multiple values separated by newline.
*/
public function getRandom(string $key, ?string $default = null): mixed
{
$key = $this->get($key, $default);
$parts = explode("\n", $key);
return $parts[array_rand($parts)];
}
public function getMenu(string $name)
{
return Arr::first(
$this->get('menus'),
fn($menu) => strtolower($menu['name']) === strtolower($name),
);
}
public function has(string $key): bool
{
return !is_null(Arr::get($this->all, $key));
}
/**
* Set single setting. Does not persist in database.
*/
public function set(string $key, mixed $value): void
{
$this->all[$key] = $value;
}
/**
* Persist specified settings in database.
*/
public function save(array $settings): void
{
$settings = $this->flatten($settings);
foreach ($settings as $key => $value) {
$setting = Setting::firstOrNew(['name' => $key]);
$setting->value = $value;
$setting->save();
$this->set($key, $setting->value);
}
Cache::forget('settings.public');
}
/**
* Get all settings parsed from dot notation to assoc array. Also decodes JSON values.
*/
public function getUnflattened(
bool $includeSecret = false,
array $settings = null,
): array {
if (!$settings) {
$settings = $this->all($includeSecret);
}
foreach ($settings as $key => $value) {
if (in_array($key, self::$jsonKeys) && is_string($value)) {
$settings[$key] = json_decode($value, true);
}
}
$dot = dot($settings, true);
return $dot->all();
}
/**
* Flatten specified assoc array into dot array. (['billing.enable' => true])
*/
public function flatten(array $settings): array
{
// this will find all json keys, encode them and remove decoded version from original array
foreach (Settings::$jsonKeys as $key) {
if (Arr::has($settings, $key)) {
$value = Arr::pull($settings, $key);
$settings[$key] = is_array($value)
? json_encode($value)
: $value;
}
}
$dot = dot($settings);
// remove keys that were added from config files and are not stored in database
$dotArray = $dot->delete(array_keys($this->configKeys))->flatten();
// dot package leaves empty array as value for root element when deleting
foreach ($dotArray as $key => $value) {
if (is_array($value) && empty($value)) {
unset($dotArray[$key]);
}
}
return $dotArray;
}
protected function find(string $key)
{
return Arr::get($this->all, $key);
}
protected function loadSettings(): void
{
$value = Cache::get('settings.public');
$this->all = collect();
if ($value && count($value) > 0) {
$this->all = $value;
} else {
try {
$value = Setting::select('name', 'value')
->get()
->pluck('value', 'name');
if (!$value->isEmpty()) {
$this->all = $value;
Cache::set('settings.public', $value, now()->addDay());
}
} catch (Exception $e) {
}
}
// add config keys that should be included
foreach ($this->configKeys as $clientKey => $configKey) {
$this->set($clientKey, config()->get($configKey));
}
}
}

View File

@@ -0,0 +1,129 @@
<?php namespace Common\Settings;
use Common\Core\AppUrl;
use Common\Core\BaseController;
use Common\Settings\Events\SettingsSaved;
use Common\Settings\Mail\ConnectGmailAccountController;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
class SettingsController extends BaseController
{
public function __construct(
protected Request $request,
protected Settings $settings,
) {
}
public function index()
{
$this->authorize('index', Setting::class);
$envSettings = (new DotEnvEditor())->load();
$envSettings['newAppUrl'] = app(AppUrl::class)->newAppUrl;
$envSettings[
'connectedGmailAccount'
] = ConnectGmailAccountController::getConnectedEmail();
// inputs on frontend can't be bound to null
foreach ($envSettings as $key => $value) {
if ($value === null) {
$envSettings[$key] = '';
}
}
return [
'server' => $envSettings,
'client' => $this->settings->getUnflattened(true),
];
}
public function persist()
{
$this->authorize('update', Setting::class);
$clientSettings = $this->cleanValues($this->request->get('client'));
$serverSettings = $this->cleanValues($this->request->get('server'));
// need to handle files before validating
$this->handleFiles();
if (
$errResponse = $this->validateSettings(
$serverSettings,
$clientSettings,
)
) {
return $errResponse;
}
if ($serverSettings) {
(new DotEnvEditor())->write($serverSettings);
}
if ($clientSettings) {
$this->settings->save($clientSettings);
}
Cache::flush();
event(new SettingsSaved($clientSettings, $serverSettings));
return $this->success();
}
private function cleanValues(string|null $config): array
{
if (!$config) {
return [];
}
$config = json_decode($config, true);
foreach ($config as $key => $value) {
$config[$key] = is_string($value) ? trim($value) : $value;
}
return $config;
}
private function handleFiles()
{
$files = $this->request->allFiles();
// store google analytics certificate file
if ($certificateFile = Arr::get($files, 'certificate')) {
File::put(
storage_path('laravel-analytics/certificate.json'),
file_get_contents($certificateFile),
);
}
}
private function validateSettings(
array $serverSettings,
array $clientSettings,
) {
// flatten "client" and "server" arrays into single array
$values = array_merge(
$serverSettings ?: [],
$clientSettings ?: [],
$this->request->allFiles(),
);
$keys = array_keys($values);
$validators = config('common.setting-validators');
foreach ($validators as $validator) {
if (empty(array_intersect($validator::KEYS, $keys))) {
continue;
}
if ($messages = app($validator)->fails($values)) {
return $this->error(
__('Could not persist settings.'),
$messages,
);
}
// catch and display any generic error that might occur
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Common\Settings\Uploading;
use Common\Core\BaseController;
use Common\Settings\DotEnvEditor;
use Illuminate\Support\Facades\Http;
class DropboxRefreshTokenController extends BaseController
{
public function generate()
{
$payload = $this->validate(request(), [
'app_key' => 'required|string',
'app_secret' => 'required|string',
'access_code' => 'required|string',
]);
$response = Http::asForm()->post(
"https://{$payload['app_key']}:{$payload['app_secret']}@api.dropbox.com/oauth2/token",
[
'grant_type' => 'authorization_code',
'code' => $payload['access_code'],
],
);
if (isset($response['refresh_token'])) {
app(DotEnvEditor::class)->write([
'STORAGE_DROPBOX_REFRESH_TOKEN' => $response['refresh_token'],
]);
return $this->success([
'refreshToken' => $response['refresh_token'],
]);
}
return $this->error($response['error_description'] ?? null);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Common\Settings\Validators;
use Common\Admin\Analytics\Actions\BuildGoogleAnalyticsReport;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
class AnalyticsCredentialsValidator
{
const KEYS = [
'analytics_property_id',
'analytics.tracking_code',
'certificate',
];
public function fails($settings): array|false
{
$this->setConfigDynamically($settings);
try {
app(BuildGoogleAnalyticsReport::class)->execute([]);
} catch (Exception $e) {
return [
'analytics_group' => "Invalid credentials: {$e->getMessage()}",
];
}
return false;
}
private function setConfigDynamically(array $settings): void
{
if ($propertyId = Arr::get($settings, 'analytics_property_id')) {
Config::set('services.google.analytics_property_id', $propertyId);
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Common\Settings\Validators;
use Common\Settings\DotEnvEditor;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Throwable;
class CacheConfigValidator
{
const KEYS = ['cache_driver'];
public function fails($settings)
{
$this->setConfigDynamically($settings);
try {
$driverName = Arr::get(
$settings,
'cache_driver',
config('cache.default'),
);
$driver = Cache::driver($driverName);
$driver->put('foo', 'bar', 1);
if ($driver->get('foo') !== 'bar') {
return $this->getDefaultErrorMessage();
}
} catch (Exception $e) {
return $this->getErrorMessage($e);
} catch (Throwable $e) {
return $this->getErrorMessage($e);
}
}
private function setConfigDynamically($settings)
{
app(DotEnvEditor::class)->write(
Arr::except($settings, ['cache_driver']),
);
}
private function getErrorMessage($e): array
{
$message = $e->getMessage();
if (Str::contains($message, 'apc_fetch')) {
return ['cache_group' => "Could not enable APC. $message"];
} elseif (Str::contains($message, 'Memcached')) {
return ['cache_group' => "Could not enable Memcached. $message"];
} elseif (Str::contains($message, 'Connection refused')) {
return ['cache_group' => 'Could not connect to redis server.'];
} else {
return $this->getDefaultErrorMessage();
}
}
private function getDefaultErrorMessage(): array
{
return ['cache_group' => 'Could not enable this cache method.'];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Common\Settings\Validators;
use Common\Auth\Oauth;
use Common\Core\HttpClient;
use Config;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Support\Arr;
use Socialite;
class FacebookLoginValidator implements SettingsValidator
{
const KEYS = ['facebook_id', 'facebook_secret'];
/**
* @var Oauth
*/
private $oauth;
/**
* @var HttpClient
*/
private $httpClient;
public function __construct(Oauth $oauth)
{
$this->oauth = $oauth;
$this->httpClient = new HttpClient([
'exceptions' => true,
]);
}
public function fails($values)
{
$this->setConfigDynamically($values);
try {
Socialite::driver('facebook')->getAccessTokenResponse('foo-bar');
} catch (ClientException $e) {
return $this->getErrorMessage($e);
} catch (ServerException $e) {
return $this->getDefaultError();
}
}
private function setConfigDynamically($settings)
{
if ($facebookId = Arr::get($settings, 'facebook_id')) {
Config::set('services.facebook.client_id', $facebookId);
}
if ($facebookSecret = Arr::get($settings, 'facebook_secret')) {
Config::set('services.facebook.client_secret', $facebookSecret);
}
}
/**
* @param ClientException $e
* @return array
*/
private function getErrorMessage(ClientException $e)
{
$errResponse = json_decode($e->getResponse()->getBody()->getContents(), true);
$code = Arr::get($errResponse, 'error.code');
// there were no credentials related errors, we can assume validation was successful
if ($code === 100) {
return null;
}
if ($code === 191) {
return ['facebook_group' => 'Site url is not present in "Valid OAuth Redirect URIs" field on your facebook app.'];
}
return $this->getDefaultError();
}
private function getDefaultError()
{
return ['facebook_group' => 'These facebook credentials are not valid.'];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Common\Settings\Validators;
use Common\Auth\Oauth;
use Common\Core\HttpClient;
use Config;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Arr;
use Socialite;
class GoogleLoginValidator implements SettingsValidator
{
const KEYS = ['google_id', 'google_secret'];
/**
* @var Oauth
*/
private $oauth;
/**
* @var HttpClient
*/
private $httpClient;
public function __construct(Oauth $oauth)
{
$this->oauth = $oauth;
$this->httpClient = new HttpClient([
'exceptions' => true,
]);
}
public function fails($values)
{
$this->setConfigDynamically($values);
try {
Socialite::driver('google')->getAccessTokenResponse('foo-bar');
} catch (ClientException $e) {
return $this->getErrorMessage($e);
}
}
private function setConfigDynamically($settings)
{
if ($googleId = Arr::get($settings, 'google_id')) {
Config::set('services.google.client_id', $googleId);
}
if ($googleSecret = Arr::get($settings, 'google_secret')) {
Config::set('services.google.client_secret', $googleSecret);
}
}
/**
* @param ClientException $e
* @return array
*/
private function getErrorMessage(ClientException $e)
{
$errResponse = json_decode(
$e
->getResponse()
->getBody()
->getContents(),
true,
);
// there were no credentials related errors, we can assume validation was successful
if (
Arr::get($errResponse, 'error_description') ===
'Malformed auth code.'
) {
return null;
}
$msg1 = Arr::get($errResponse, 'error.errors.0.message', '');
$msg2 = Arr::get($errResponse, 'error_description', '');
$message = strtolower($msg1 ?: $msg2);
return [
'google_group' => "Could not validate these credentials: $message",
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Common\Settings\Validators;
use Exception;
use Sentry\Dsn;
class LoggingCredentialsValidator
{
const KEYS = ['sentry_dsn'];
public function fails($settings)
{
try {
Dsn::createFromString($settings['sentry_dsn']);
} catch (Exception $e) {
return $this->getErrorMessage($e);
}
}
/**
* @param Exception $e
* @return array
*/
private function getErrorMessage($e)
{
return ['logging_group' => 'This sentry DSN is not valid.'];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Common\Settings\Validators\MailCredentials;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;
class MailCredentialsMailable extends Mailable
{
use Queueable, SerializesModels;
/**
* @return void
*/
public function __construct()
{
//
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this
->markdown('common::emails.mail-validation')
->subject(config('app.name') . ' Mail Set Up Successfully!');
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace Common\Settings\Validators\MailCredentials;
use Arr;
use Auth;
use Aws\Ses\Exception\SesException;
use Common\CommonServiceProvider;
use Common\Settings\DotEnvEditor;
use Common\Settings\Validators\SettingsValidator;
use Config;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Mail\MailServiceProvider;
use Mail;
use Str;
class OutgoingMailCredentialsValidator implements SettingsValidator
{
const KEYS = [
'mail_driver',
'mail_host',
'mail_username',
'mail_password',
'mail_port',
'mail_encryption', // SMTP
'mailgun_domain',
'mailgun_secret', // Mailgun
'ses_key',
'ses_secret', // Amazon SES
'sparkpost_secret', // Sparkpost
];
public function fails($values)
{
$this->setConfigDynamically($values);
try {
Mail::to(Auth::user()->email)->send(new MailCredentialsMailable());
} catch (Exception $e) {
app(DotEnvEditor::class)->write(['MAIL_SETUP' => false]);
return $this->getErrorMessage($e);
}
app(DotEnvEditor::class)->write(['MAIL_SETUP' => true]);
}
private function setConfigDynamically($settings)
{
foreach ($settings as $key => $value) {
//mail_host => mail.host
$key = str_replace('_', '.', $key);
// "mail.*" credentials go into "mail.php" config
// file, other credentials go into "services.php"
if ($key === 'mail.driver') {
$key = 'mail.default';
} elseif ($key === 'mail_from_address') {
$key = 'mail.from.address';
} elseif (!Str::startsWith($key, 'mail.')) {
$key = "services.$key";
} else {
$key = str_replace('mail.', 'mail.mailers.smtp.', $key);
}
Config::set($key, $value);
}
// make sure laravel uses newly set config
(new MailServiceProvider(app()))->register();
(new CommonServiceProvider(app()))->registerCustomMailDrivers();
}
/**
* @param Exception|ClientException $e
* @return array
*/
private function getErrorMessage($e)
{
$message = null;
if (config('mail.driver') === 'smtp') {
$message = $this->getSmtpMessage($e);
} elseif (config('mail.driver') === 'mailgun') {
$message = $this->getMailgunMessage($e);
} elseif (config('mail.driver') === 'ses') {
$message = $this->getSesMessage($e);
}
return $message ?: $this->getDefaultMessage($e);
}
private function getSesMessage(SesException $e)
{
return ['mail_group' => $e->getAwsErrorMessage()];
}
private function getMailgunMessage(ClientException $e)
{
$originalContents = $e
->getResponse()
->getBody()
->getContents();
$errResponse = json_decode($originalContents, true);
if (is_null($errResponse) && is_string($originalContents)) {
$errResponse = $originalContents;
}
$message = strtolower(Arr::get($errResponse, 'message', $errResponse));
if (Str::contains($message, 'domain not found')) {
return [
'server.mailgun_domain' => 'This mailgun domain is not valid.',
];
} elseif (Str::contains($message, 'forbidden')) {
return [
'server.mailgun_secret' => 'This mailgun API Key is not valid.',
];
}
return [
'mail_group' =>
'Could not validate mailgun credentials. Please double check them.',
];
}
private function getSmtpMessage(Exception $e): ?array
{
if (Str::contains($e->getMessage(), 'Connection timed out #110')) {
return [
'mail_group' =>
'Connection to mail server timed out. This usually indicates incorrect mail credentials. Please double check them.',
];
}
return null;
}
private function getDefaultMessage(Exception $e): array
{
return [
'mail_group' => "Could not validate mail credentials: <br> {$e->getMessage()}",
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Common\Settings\Validators;
use Common\Billing\Gateways\Paypal\Paypal;
use Common\Settings\Settings;
use Config;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Arr;
class PaypalCredentialsValidator implements SettingsValidator
{
const KEYS = [
'paypal_client_id',
'paypal_secret',
'paypal_webhook_id',
'billing.paypal_test_mode',
];
/**
* @var Settings
*/
private $settings;
/**
* @param Settings $settings
*/
public function __construct(Settings $settings)
{
$this->settings = $settings;
}
public function fails($values)
{
$this->setConfigDynamically($values);
// create gateway after setting config dynamically
// so gateway uses new configuration
try {
$response = app(Paypal::class)
->paypal()
->get('payments/billing-plans');
if (!$response->successful()) {
return $this->getErrorMessage($response->body());
}
} catch (ClientException $e) {
return $this->getDefaultError();
}
}
private function setConfigDynamically($settings)
{
foreach (self::KEYS as $key) {
if (!Arr::has($settings, $key)) {
continue;
}
if ($key === 'billing.paypal_test_mode') {
$this->settings->set(
'billing.paypal_test_mode',
$settings[$key],
);
} else {
// paypal_client_id => client_id
$configKey = str_replace('paypal_', '', $key);
Config::set("services.paypal.$configKey", $settings[$key]);
}
}
}
/**
* @param array $data
* @return array
*/
private function getErrorMessage($data)
{
$message = Arr::get($data, 'message');
if ($data['name'] === 'AUTHENTICATION_FAILURE') {
return [
'paypal_group' =>
'Paypal Client ID or Paypal Secret is invalid.',
];
} elseif ($message) {
$infoLink = Arr::get($data, 'information_link');
return ['paypal_group' => "$message. $infoLink"];
} else {
return $this->getDefaultError();
}
}
private function getDefaultError()
{
return ['paypal_group' => 'These paypal credentials are not valid.'];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Common\Settings\Validators;
use Config;
use Queue;
use Exception;
use Illuminate\Support\Arr;
class QueueCredentialsValidator
{
const KEYS = [
'queue_driver',
// sqs
'SQS_QUEUE_KEY', 'SQS_QUEUE_SECRET', 'SQS_QUEUE_PREFIX', 'SQS_QUEUE_NAME', 'SQS_QUEUE_REGION',
];
public function fails($settings)
{
$this->setConfigDynamically($settings);
$driver = Arr::get($settings, 'queue_driver', config('queue.default'));
try {
Queue::connection($driver)->size();
} catch (Exception $e) {
return $this->getErrorMessage($e, $driver);
}
}
private function setConfigDynamically($settings)
{
foreach ($settings as $key => $value) {
// SQS_QUEUE_KEY => sqs.queue.key
$key = strtolower(str_replace('_', '.', $key));
// sqs.queue.key => sqs.key
$key = str_replace('queue.', '', $key);
$key = str_replace('name', 'queue', $key);
Config::set("queue.connections.$key", $value);
}
}
/**
* @param Exception $e
* @param string $driver
* @return array
*/
private function getErrorMessage($e, $driver)
{
return ['queue_group' => "Could not change queue driver to <strong>$driver</strong>.<br> {$e->getMessage()}"];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Settings\Validators;
use Config;
use Exception;
use Arr;
use Pusher\Pusher;
class RealtimeCredentialsValidator
{
const KEYS = ['pusher_key', 'pusher_secret', 'pusher_app_id', 'pusher_cluster'];
public function fails($settings)
{
$this->setConfigDynamically($settings);
try {
$config = Config::get('broadcasting.connections.pusher');
$pusher = new Pusher($config['key'], $config['secret'],
$config['app_id'], Arr::get($config, 'options', []));
if ($pusher->get_channels() === false) {
return $this->getErrorMessage();
}
} catch (Exception $e) {
return $this->getErrorMessage();
}
}
private function setConfigDynamically($settings)
{
foreach (self::KEYS as $key) {
if ( ! Arr::has($settings, $key)) continue;
if ($key === 'pusher_cluster') {
Config::set("broadcasting.connections.pusher.options.cluster", $settings[$key]);
} else {
$configKey = str_replace('pusher_', '', $key);
Config::set("broadcasting.connections.pusher.$configKey", $settings[$key]);
}
}
}
/**
* @param Exception $e
* @return array
*/
private function getErrorMessage($e = null)
{
return ['pusher_group' => 'These pusher credentials are not valid.'];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Common\Settings\Validators;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
class RecaptchaCredentialsValidator
{
const KEYS = ['recaptcha.site_key', 'recaptcha.secret_key'];
public function fails($settings): array|false
{
try {
$response = Http::asForm()->post(
'https://www.google.com/recaptcha/api/siteverify',
[
'response' => 'foo-bar',
'secret' => Arr::get($settings, 'recaptcha.secret_key'),
],
);
if (
$response['success'] === false &&
$response['error-codes'][0] !== 'invalid-input-response'
) {
return [
'recaptcha_group' =>
Arr::get($response, 'error-codes')[0] ??
__('These credentials are not valid'),
];
}
} catch (Exception $e) {
return $this->getErrorMessage($e);
}
return false;
}
private function getErrorMessage(Exception $e): array
{
return ['recaptcha_group' => $e->getMessage()];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Common\Settings\Validators;
use App\Models\User;
use Arr;
use Exception;
use Laravel\Scout\Builder;
use Laravel\Scout\EngineManager;
use Matchish\ScoutElasticSearch\ElasticSearchServiceProvider;
use Matchish\ScoutElasticSearch\Engines\ElasticSearchEngine;
use Throwable;
class SearchConfigValidator
{
const KEYS = ['scout_driver'];
public function fails($settings)
{
$engineName = Arr::get(
$settings,
'scout_driver',
config('scout.driver'),
);
$manager = app(EngineManager::class);
if (isset($settings['algolia_app_id'])) {
config()->set('scout.algolia.id', $settings['algolia_app_id']);
}
if (isset($settings['algolia_secret'])) {
config()->set('scout.algolia.secret', $settings['algolia_secret']);
}
if (
$engineName === 'mysql' &&
Arr::get($settings, 'scout_mysql_mode') !== 'fulltext'
) {
return false;
}
// register elastic search provider, if not registered already
if (
$engineName === ElasticSearchEngine::class &&
empty(app()->getProviders(ElasticSearchServiceProvider::class))
) {
app()->register(ElasticSearchServiceProvider::class);
}
$results = $manager->engine($engineName)->search(
app(Builder::class, [
'model' => new User(),
'query' => 'test',
]),
);
if (!$results) {
return $this->getDefaultErrorMessage();
}
}
/**
* @param Exception|Throwable $e
* @return array
*/
private function getErrorMessage($e)
{
$message = $e->getMessage();
return [
'search_group' => "Could not enable this search method: $message",
];
}
/**
* @return array
*/
private function getDefaultErrorMessage()
{
return ['search_group' => 'Could not enable this search method.'];
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Settings\Validators;
interface SettingsValidator
{
/**
* @param array $values
* @return null|array
*/
public function fails($values);
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Common\Settings\Validators;
use Common\Files\Actions\CreateFileEntry;
use Common\Files\Actions\Deletion\PermanentlyDeleteEntries;
use Common\Files\Actions\StoreFile;
use Common\Files\FileEntryPayload;
use Common\Settings\DotEnvEditor;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class StaticFileDeliveryValidator implements SettingsValidator
{
const KEYS = ['static_file_delivery'];
public function fails($values): bool|array
{
if (!$values['static_file_delivery']) {
return false;
}
$originalDelivery = config('common.site.static_file_delivery');
$originalDriver = config('common.site.uploads_disk_driver');
app(DotEnvEditor::class)->write([
'STATIC_FILE_DELIVERY' => $values['static_file_delivery'],
'UPLOADS_DISK_DRIVER' => 'local',
]);
$previewToken = Str::random(10);
$contents = Str::random(10);
$path = base_path('common/resources/lorem.html');
$uploadedFile = new UploadedFile(
$path,
basename($path),
'text/html',
filesize($path),
);
$payload = new FileEntryPayload([
'file' => $uploadedFile,
]);
$fileEntry = app(CreateFileEntry::class)->execute($payload);
$fileEntry->fill(['preview_token' => $previewToken])->save();
app(StoreFile::class)->execute($payload, ['file' => $uploadedFile]);
$response = Http::get(
url($fileEntry->url) . "?preview_token=$previewToken",
);
app(PermanentlyDeleteEntries::class)->execute([$fileEntry->id]);
app(DotEnvEditor::class)->write([
'STATIC_FILE_DELIVERY' => $originalDelivery,
'UPLOADS_DISK_DRIVER' => $originalDriver,
]);
if ($contents !== $response->body()) {
return [
'static_delivery_group' => __(
'Could not validate selected optimization. Is it enabled on the server?',
),
];
} else {
return false;
}
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace Common\Settings\Validators;
use Aws\S3\Exception\S3Exception;
use Common\Files\Providers\BackblazeServiceProvider;
use Common\Files\Providers\DigitalOceanServiceProvider;
use Common\Files\Providers\DropboxServiceProvider;
use Config;
use Exception;
use Spatie\FlysystemDropbox\DropboxAdapter;
use Storage;
use Str;
class StorageCredentialsValidator
{
const KEYS = [
'uploads_disk_driver',
'public_disk_driver',
// dropbox
'storage_dropbox_access_token',
'storage_dropbox_refresh_token',
'storage_dropbox_app_key',
'storage_dropbox_app_secret',
// s3
'storage_s3_key',
'storage_s3_secret',
'storage_s3_region',
'storage_s3_bucket',
// ftp
'storage_ftp_host',
'storage_ftp_username',
'storage_ftp_password',
'storage_ftp_root',
'storage_ftp_port',
'storage_ftp_passive',
'storage_ftp_ssl',
// digital ocean
'storage_digitalocean_key',
'storage_digitalocean_secret',
'storage_digitalocean_region',
'storage_digitalocean_bucket',
// rackspace
'storage_rackspace_username',
'storage_rackspace_key',
'storage_rackspace_region',
'storage_rackspace_container',
// backblaze
'storage_backblaze_key',
'storage_backblaze_secret',
'storage_backblaze_bucket',
'storage_backblaze_region',
];
public function fails($settings)
{
$this->setConfigDynamically($settings);
$this->registerAdapters();
$messages = array_merge(
is_null(config('common.site.uploads_disk_driver'))
? []
: $this->validateDisk('uploads'),
$this->validateDisk('public'),
);
return empty($messages) ? false : $messages;
}
private function validateDisk(string $diskName): array
{
$driverName = Config::get("common.site.{$diskName}_disk_driver");
try {
$disk = Storage::disk($diskName);
if ($disk->getAdapter() instanceof DropboxAdapter) {
// dropbox adapter catches all errors silently
// need to use client directly to check for errors
$disk
->getAdapter()
->getClient()
->listFolder();
} else {
$disk->allFiles();
}
} catch (S3Exception $e) {
return $this->getS3Message($e);
} catch (Exception $e) {
$message = $e->getMessage();
if (
Str::contains(
$message,
'ftp_chdir(): Failed to change directory',
)
) {
$message =
'Could not open "uploads" directory. You might need to create it manually via any FTP manager.';
}
return [
'storage_group' => "Invalid $driverName credentials.<br>{$message}",
];
}
return [];
}
private function getS3Message(S3Exception $e): array
{
return [
'storage_group' => "Could not validate credentials. <br> {$e->getAwsErrorMessage()}",
];
}
private function setConfigDynamically($settings): void
{
$replacements = [
's3',
'dropbox',
'ftp',
'digitalocean',
'rackspace',
'backblaze',
];
foreach ($settings as $key => $value) {
if ($key === 'uploads_disk_driver') {
Config::set('common.site.uploads_disk_driver', $value ?: null);
} elseif ($key === 'public_disk_driver') {
Config::set('common.site.public_disk_driver', $value ?: null);
} else {
// uploads_s3_key => services.s3.key
$key = str_replace('storage_', '', $key);
$key = preg_replace('/_/', '.', $key, 1);
$key = "services.$key";
foreach ($replacements as $replacement) {
$key = str_replace(
"{$replacement}_",
"{$replacement}.",
$key,
);
}
$key = str_replace('digitalocean.', 'digitalocean_s3.', $key);
$key = str_replace('backblaze.', 'backblaze_s3.', $key);
Config::set($key, $value ?: null);
}
}
}
private function registerAdapters(): void
{
app()->register(DigitalOceanServiceProvider::class);
app()->register(DropboxServiceProvider::class);
app()->register(BackblazeServiceProvider::class);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Common\Settings\Validators;
use Common\Billing\Gateways\Stripe\Stripe;
use Config;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Arr;
class StripeCredentialsValidator implements SettingsValidator
{
const KEYS = ['stripe_key', 'stripe_secret'];
public function fails($values)
{
$this->setConfigDynamically($values);
// create gateway after setting config dynamically
// so gateway uses new configuration
$gateway = app(Stripe::class);
try {
$gateway->getAllPlans();
} catch (ClientException $e) {
return $this->getDefaultError();
}
}
private function setConfigDynamically($settings)
{
foreach (self::KEYS as $key) {
if (!Arr::has($settings, $key)) {
continue;
}
// stripe_key => key
$configKey = str_replace('stripe_', '', $key);
Config::set("services.stripe.$configKey", $settings[$key]);
}
}
private function getDefaultError(): array
{
return ['stripe_group' => 'These stripe credentials are not valid.'];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Common\Settings\Validators;
use Common\Auth\Oauth;
use Common\Core\HttpClient;
use Config;
use Exception;
use Illuminate\Support\Arr;
use Socialite;
class TwitterLoginValidator implements SettingsValidator
{
const KEYS = ['twitter_id', 'twitter_secret'];
/**
* @var Oauth
*/
private $oauth;
/**
* @var HttpClient
*/
private $httpClient;
public function __construct(Oauth $oauth)
{
$this->oauth = $oauth;
$this->httpClient = new HttpClient([
'exceptions' => true,
]);
}
public function fails($values)
{
$this->setConfigDynamically($values);
try {
Socialite::driver('twitter')->redirect();
} catch (Exception $e) {
return $this->getErrorMessage($e);
}
}
private function setConfigDynamically($settings)
{
if ($twitterId = Arr::get($settings, 'twitter_id')) {
Config::set('services.twitter.client_id', $twitterId);
}
if ($twitterSecret = Arr::get($settings, 'twitter_secret')) {
Config::set('services.twitter.client_secret', $twitterSecret);
}
}
/**
* @param Exception $e
* @return array
*/
private function getErrorMessage(Exception $e)
{
if (\Str::contains($e->getMessage(), 'code="415"')) {
return ['twitter_group' => 'Site url is not present in "Callback URL" field on your twitter app.'];
}
return $this->getDefaultError();
}
private function getDefaultError()
{
return ['twitter_group' => 'These twitter credentials are not valid.'];
}
}