diff --git a/ProcessMaker/Application.php b/ProcessMaker/Application.php index 023fc26c4f..b459358dd3 100644 --- a/ProcessMaker/Application.php +++ b/ProcessMaker/Application.php @@ -5,14 +5,25 @@ use Igaster\LaravelTheme\Facades\Theme; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application as IlluminateApplication; +use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; +use Illuminate\Foundation\Bootstrap\RegisterProviders; use Illuminate\Foundation\PackageManifest; +use Illuminate\Support\Env; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Config; +use ProcessMaker\Multitenancy\Tenant; +use ProcessMaker\Multitenancy\TenantBootstrapper; /** * Class Application. */ class Application extends IlluminateApplication { + public $overrideTenantId = null; + + public $skipCacheEvents = false; + /** * Sets the timezone for the application and for php with the specified timezone. * @@ -90,4 +101,15 @@ public function registerConfiguredProviders() parent::registerConfiguredProviders(); } + + public function bootstrapWith(array $bootstrappers) + { + // Insert TenantBootstrapper after LoadEnvironmentVariables + if ($bootstrappers[0] !== LoadEnvironmentVariables::class) { + throw new \Exception('LoadEnvironmentVariables is not the first bootstrapper. Did a laravel upgrade change this?'); + } + array_splice($bootstrappers, 1, 0, [TenantBootstrapper::class]); + + return parent::bootstrapWith($bootstrappers); + } } diff --git a/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php b/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php index 2918b4eb83..fd01a81ef8 100644 --- a/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php +++ b/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php @@ -34,9 +34,9 @@ class ProcessMakerLicenseRemove extends Command */ public function handle() { - if (Storage::disk('root')->exists('license.json')) { + if (Storage::disk('local')->exists('license.json')) { if ($this->option('force') || $this->confirm('Are you sure you want to remove the license.json file?')) { - Storage::disk('root')->delete('license.json'); + Storage::disk('local')->delete('license.json'); $this->info('license.json removed successfully!'); $this->info('Calling package:discover to update the package cache with enabled packages'); diff --git a/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php b/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php index cb6a3bce10..83c1a72a5d 100644 --- a/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php +++ b/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php @@ -40,7 +40,7 @@ public function handle() return 1; } - Storage::disk('root')->put('license.json', $content); + Storage::disk('local')->put('license.json', $content); $this->info('Calling package:discover to update the package cache with enabled packages'); Artisan::call('package:discover'); diff --git a/ProcessMaker/Console/Commands/TenantsVerify.php b/ProcessMaker/Console/Commands/TenantsVerify.php index c5294012fc..36ff3b01ac 100644 --- a/ProcessMaker/Console/Commands/TenantsVerify.php +++ b/ProcessMaker/Console/Commands/TenantsVerify.php @@ -3,8 +3,13 @@ namespace ProcessMaker\Console\Commands; use Illuminate\Console\Command; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; +use ProcessMaker\Models\EnvironmentVariable; +use ProcessMaker\Models\User; use Spatie\Multitenancy\Models\Tenant; class TenantsVerify extends Command @@ -14,7 +19,7 @@ class TenantsVerify extends Command * * @var string */ - protected $signature = 'tenants:verify {--verify-against= : The tenant ID to verify against}'; + protected $signature = 'tenants:verify'; /** * The console command description. @@ -23,17 +28,6 @@ class TenantsVerify extends Command */ protected $description = 'Verify tenant configuration and storage paths'; - /** - * Strip protocol from URL - * - * @param string $url - * @return string - */ - private function stripProtocol(string $url): string - { - return preg_replace('#^https?://#', '', $url); - } - /** * Execute the console command. * @@ -46,85 +40,72 @@ public function handle() $currentTenant = app('currentTenant'); } - $verifyAgainstId = $this->option('verify-against'); - - if (!$currentTenant) { - $this->error('No current tenant found'); + if (config('app.multitenancy') && !$currentTenant) { + $this->error('Multitenancy enabled but no current tenant found.'); return; } - $this->info('Current Tenant ID: ' . $currentTenant->id); - $this->line('----------------------------------------'); + $this->info('Current Tenant ID: ' . ($currentTenant?->id ?? 'NONE')); - // Expected paths and configurations - $expectedStoragePath = base_path('storage/tenant_' . $currentTenant->id); - $actualConfigs = [ - 'filesystems.disks.local.root' => storage_path('app'), - 'cache.prefix' => config('cache.prefix'), - 'app.url' => config('app.url'), - 'script-runner-microservice.callback' => config('script-runner-microservice.callback'), + $paths = [ + ['Storage Path', storage_path()], + ['Config Cache Path', app()->getCachedConfigPath()], + ['Lang Path', lang_path()], ]; - // Display current values - $this->info('Current Storage Path: ' . storage_path()); - $this->line('----------------------------------------'); - - $this->info('Current Configuration Values:'); - foreach ($actualConfigs as $key => $expectedValue) { - $currentValue = config($key); - $this->line("{$key}: {$currentValue}"); - } - - // If verify-against is specified, perform verification - if ($verifyAgainstId) { - $this->line('----------------------------------------'); - $this->info("Verifying against tenant ID: {$verifyAgainstId}"); + // Display paths in a nice table + $this->table(['Path', 'Value'], $paths); + + $configs = [ + 'app.key', + 'app.url', + 'app.instance', + 'cache.prefix', + 'database.redis.options.prefix', + 'cache.stores.cache_settings.prefix', + 'script-runner-microservice.callback', + 'database.connections.processmaker.database', + 'logging.channels.daily.path', + 'filesystems.disks.public.root', + 'filesystems.disks.local.root', + 'filesystems.disks.lang.root', + ]; - $expectedStoragePath = base_path('storage/tenant_' . $verifyAgainstId); - $expectedConfigs = [ - 'filesystems.disks.local.root' => $expectedStoragePath . '/app', - 'cache.prefix' => 'tenant_id_' . $verifyAgainstId, - 'app.url' => config('app.url'), + $configs = array_map(function ($config) { + return [ + $config, + config($config), ]; - - $hasMismatch = false; - - // Verify storage path - if (storage_path() !== $expectedStoragePath) { - $this->error('Storage path mismatch!'); - $this->line("Expected: {$expectedStoragePath}"); - $this->line('Current: ' . storage_path()); - $hasMismatch = true; - } - - // Verify tenant URL if tenant exists - $verifyTenant = Tenant::find($verifyAgainstId); - if ($verifyTenant && $verifyTenant->domain !== $this->stripProtocol(config('app.url'))) { - $this->error('Tenant URL mismatch!'); - $this->line("Expected: {$verifyTenant->domain}"); - $this->line('Current: ' . config('app.url')); - $hasMismatch = true; - } - - // Verify config values - foreach ($expectedConfigs as $key => $expectedValue) { - $currentValue = config($key); - if ($currentValue !== $expectedValue) { - $this->error("Config mismatch for {$key}!"); - $this->line("Expected: {$expectedValue}"); - $this->line("Current: {$currentValue}"); - $hasMismatch = true; - } + }, $configs); + + // Display configs in a nice table + $this->table(['Config', 'Value'], $configs); + + $env = EnvironmentVariable::first(); + if (!$env) { + $decrypted = 'No environment variables found to test decryption'; + } else { + $encryptedValue = $env->getAttributes()['value']; + try { + Crypt::decryptString($encryptedValue); + $decrypted = 'OK'; + } catch (DecryptException $e) { + $decrypted = 'FAILED! ' . $e->getMessage(); } - - if (!$hasMismatch) { - $this->info('All configurations match as expected!'); - } - - return $hasMismatch ? Command::FAILURE : Command::SUCCESS; } - return Command::SUCCESS; + $other = [ + ['Landlord Config Cache Path', base_path('bootstrap/cache/config.php')], + ['Landlord Config Is Cached', File::exists(base_path('bootstrap/cache/config.php')) ? 'Yes' : 'No'], + ['Tenant Config Cache Path', app()->getCachedConfigPath()], + ['Tenant Config Is Cached', File::exists(app()->getCachedConfigPath()) ? 'Yes' : 'No'], + ['First username (database check)', User::first()?->username ?? 'No users found'], + ['Decrypted check', substr($decrypted, 0, 50)], + ['Original App URL (landlord)', $currentTenant?->getOriginalValue('APP_URL') ?? config('app.url')], + ]; + + // Display other in a nice table + $this->table(['Other', 'Value'], $other); } } diff --git a/ProcessMaker/Exception/Handler.php b/ProcessMaker/Exception/Handler.php index 86dd173454..3566ca8664 100644 --- a/ProcessMaker/Exception/Handler.php +++ b/ProcessMaker/Exception/Handler.php @@ -43,6 +43,11 @@ class Handler extends ExceptionHandler */ public function report(Throwable $exception) { + if (!App::getFacadeRoot()) { + error_log(get_class($exception) . ': ' . $exception->getMessage()); + + return; + } if (App::environment() == 'testing' && env('TESTING_VERBOSE')) { // If we're verbose, we should print ALL Exceptions to the screen echo $exception->getMessage() . "\n"; @@ -146,18 +151,4 @@ protected function convertExceptionToArray(Throwable $e) 'message' => $this->isHttpException($e) ? $e->getMessage() : 'Server Error', ]; } - - /** - * Errors in the console must have an exit status > 0 for CI to see it as an error. - * This prevents the symfony console from handling the error and returning an - * exit status of 0, which it does by default surprisingly. - * - * @param \Symfony\Component\Console\Output\OutputInterface $output - * @param Throwable $e - * @return void - */ - public function renderForConsole($output, Throwable $e) - { - throw $e; - } } diff --git a/ProcessMaker/Jobs/RefreshArtisanCaches.php b/ProcessMaker/Jobs/RefreshArtisanCaches.php index 930441c631..6aeb31e5e0 100644 --- a/ProcessMaker/Jobs/RefreshArtisanCaches.php +++ b/ProcessMaker/Jobs/RefreshArtisanCaches.php @@ -3,17 +3,20 @@ namespace ProcessMaker\Jobs; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Support\Facades\Artisan; -class RefreshArtisanCaches implements ShouldQueue +class RefreshArtisanCaches implements ShouldQueue, ShouldBeUnique { use Dispatchable, InteractsWithQueue, Queueable; - public $tries = 1; + public $tries = 2; // One extra try to handle the debounce release + + public $queuedAt; /** * Create a new job instance. @@ -22,7 +25,7 @@ class RefreshArtisanCaches implements ShouldQueue */ public function __construct() { - // + $this->queuedAt = time(); } /** @@ -32,7 +35,9 @@ public function __construct() */ public function middleware(): array { - return [(new WithoutOverlapping('refresh_artisan_caches'))->dontRelease()]; + return [ + (new WithoutOverlapping('refresh_artisan_caches'))->dontRelease(), + ]; } /** @@ -49,6 +54,13 @@ public function handle() return; } + // Wait 3 seconds before running the job - debounce + if ($this->queuedAt && $this->queuedAt >= time() - 3) { + $this->release(3); + + return; + } + $options = [ '--no-interaction' => true, '--quiet' => true, @@ -56,7 +68,12 @@ public function handle() if (app()->configurationIsCached()) { Artisan::call('config:cache', $options); + } else { + Artisan::call('queue:restart', $options); + + // We call this manually here since this job is dispatched + // automatically when the config *is* cached + RestartMessageConsumers::dispatchSync(); } - Artisan::call('queue:restart', $options); } } diff --git a/ProcessMaker/LicensedPackageManifest.php b/ProcessMaker/LicensedPackageManifest.php index abed47193e..cac7ca6243 100644 --- a/ProcessMaker/LicensedPackageManifest.php +++ b/ProcessMaker/LicensedPackageManifest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use ProcessMaker\Providers\ProcessMakerServiceProvider; +use Spatie\Multitenancy\MultitenancyServiceProvider; use Throwable; class LicensedPackageManifest extends PackageManifest @@ -20,20 +22,6 @@ class LicensedPackageManifest extends PackageManifest const LAST_PACKAGE_DISCOVERY = 0; - /** - * Consider this the beginning of licenesing refactor for multitenancy. - * - * For now, this will just move the Spatie MultitenancyServiceProvider to the beginning of the service providers. - */ - protected function getManifest() - { - $manifest = parent::getManifest(); - $multitenancyKey = 'spatie/laravel-multitenancy'; - - // Make sure the MultitenancyServiceProvider is at the beginning of the manifest - return [$multitenancyKey => $manifest[$multitenancyKey]] + $manifest; - } - protected function packagesToIgnore() { $packagesToIgnore = $this->loadPackagesToIgnore()->all(); @@ -66,7 +54,7 @@ private function parseLicense() if (!$this->hasLicenseFile()) { return null; } - $license = Storage::disk('root')->get('license.json'); + $license = Storage::disk('local')->get('license.json'); return json_decode($license, true); } @@ -87,7 +75,7 @@ private function licensedPackages() private function hasLicenseFile() { - return Storage::disk('root')->exists('license.json'); + return Storage::disk('local')->exists('license.json'); } private function setExpireCache() diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index e522798a8c..1902a0b8e5 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -3,13 +3,8 @@ namespace ProcessMaker\Multitenancy; use Illuminate\Broadcasting\BroadcastManager; -use Illuminate\Bus\Dispatcher; -use Illuminate\Contracts\Queue\Factory as QueueFactoryContract; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Crypt; -use Illuminate\Support\Facades\DB; +use ProcessMaker\Application; use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager; -use ProcessMaker\Multitenancy\TenantAwareDispatcher; use Spatie\Multitenancy\Concerns\UsesMultitenancyConfig; use Spatie\Multitenancy\Contracts\IsTenant; use Spatie\Multitenancy\Tasks\SwitchTenantTask; @@ -18,8 +13,6 @@ class SwitchTenant implements SwitchTenantTask { use UsesMultitenancyConfig; - public static $originalConfig = null; - /** * Make the given tenant current. * @@ -28,103 +21,19 @@ class SwitchTenant implements SwitchTenantTask */ public function makeCurrent(IsTenant $tenant): void { - \Log::debug('SwitchTenant: ' . $tenant->id, ['domain' => request()->getHost()]); + $app = app(); - $this->setTenantDatabaseConnection($tenant); + \Log::debug('SwitchTenant: ' . $tenant->id, ['domain' => request()->getHost()]); // Set the tenant's domain in the request headers. Used for things like the global url() helper. request()->headers->set('host', $tenant->domain); - // Set the tenant-specific storage path - $tenantStoragePath = base_path('storage/tenant_' . $tenant->id); - - $app = app(); - $app->useStoragePath($tenantStoragePath); - - // Use tenant's translation files - $app->useLangPath(resource_path('lang/tenant_' . $tenant->id)); - - // Create the tenant storage directory if it doesn't exist - // TODO: Move these to somewhere else - should not be run on every request - if (!file_exists($tenantStoragePath)) { - mkdir($tenantStoragePath, 0755, true); - } - - // Any config that relies on the original value needs to be saved in a static variable. - // Otherwise, it will use the last tenant's modified value. This mostly only affects - // the worker queue jobs since it reuses the same process. - self::$originalConfig = self::$originalConfig ?? []; - self::$originalConfig[$tenant->id] = self::$originalConfig[$tenant->id] ?? [ - 'app.url' => config('app.url'), - 'cache.stores.cache_settings.prefix' => config('cache.stores.cache_settings.prefix'), - 'app.instance' => config('app.instance') ?? config('database.connections.landlord.database'), - 'script-runner-microservice.callback' => config('script-runner-microservice.callback'), - ]; - - // We cant reload config here with (new LoadConfiguration())->bootstrap($app); - // because it overrides dynamic configs set in packages (like docker-executor-php) - // Instead, override each necessary config value on the fly. - $newConfig = [ - 'filesystems.disks.local.root' => storage_path('app'), - 'filesystems.disks.public.root' => storage_path('app/public'), - 'filesystems.disks.public.url' => $tenant->config['app.url'] . '/storage', - 'filesystems.disks.profile.root' => storage_path('app/public/profile'), - 'filesystems.disks.profile.url' => $tenant->config['app.url'] . '/storage/profile', - 'filesystems.disks.settings.root' => storage_path('app/public/setting'), - 'filesystems.disks.settings.url' => $tenant->config['app.url'] . '/storage/setting', - 'filesystems.disks.private_settings.root' => storage_path('app/private/settings'), - 'filesystems.disks.web_services.root' => storage_path('app/private/web_services'), - 'filesystems.disks.tmp.root' => storage_path('app/public/tmp'), - 'filesystems.disks.tmp.url' => $tenant->config['app.url'] . '/storage/tmp', - 'filesystems.disks.samlidp.root' => storage_path('samlidp'), - 'filesystems.disks.decision_tables.root' => storage_path('decision-tables'), - 'filesystems.disks.decision_tables.url' => $tenant->config['app.url'] . '/storage/decision-tables', - 'filesystems.disks.lang.root' => lang_path(), - 'l5-swagger.defaults.paths.docs' => storage_path('api-docs'), - 'app.instance' => self::$originalConfig[$tenant->id]['app.instance'] . '_' . $tenant->id, - ]; - - if (!isset($tenant->config['cache.stores.cache_settings.prefix'])) { - $newConfig['cache.stores.cache_settings.prefix'] = - 'tenant_id_' . $tenant->id . ':' . self::$originalConfig[$tenant->id]['cache.stores.cache_settings.prefix']; - } - - if (!isset($tenant->config['script-runner-microservice.callback'])) { - $newConfig['script-runner-microservice.callback'] = str_replace( - self::$originalConfig[$tenant->id]['app.url'], - $tenant->config['app.url'], - self::$originalConfig[$tenant->id]['script-runner-microservice.callback'] - ); - } - - if (!isset($tenant->config['app.docker_host_url'])) { - // There is no specific override in the tenant's config so set it to the app url - $newConfig['app.docker_host_url'] = $tenant->config['app.url']; - } - - config($newConfig); - - // Set config from the entry in the tenants table - $config = $tenant->config; - if (isset($config['app.key'])) { - // Decrypt using the landlord APP_KEY in the .env file. - // All encryption after this will use the tenant's key. - $config['app.key'] = Crypt::decryptString($config['app.key']); - } - config($config); - - // The previous app key was saved in the singleton, so we need to forget it. - $app->forgetInstance('encrypter'); + $this->overrideConfigs($app, $tenant); // Extend BroadcastManager to our custom implementation that prefixes the channel names with the tenant id. $app->extend(BroadcastManager::class, function ($manager, $app) use ($tenant) { return new TenantAwareBroadcastManager($app, $tenant->id); }); - - // Extend Dispatcher to our custom implementation that prefixes the queue names with the tenant id. - $app->extend(Dispatcher::class, function ($dispatcher, $app) use ($tenant) { - return new TenantAwareDispatcher($app, $dispatcher, $tenant->id); - }); } /** @@ -136,51 +45,43 @@ public function forgetCurrent(): void { } - /** - * Set the tenant database connection. - * - * Copied from laravel-multitenancy's src/Tasks/SwitchTenantDatabaseTask.php - * - * @param IsTenant $tenant - * @return void - */ - private function setTenantDatabaseConnection(IsTenant $tenant): void + private function overrideConfigs(Application $app, IsTenant $tenant) { - $tenantConnectionName = $this->tenantDatabaseConnectionName(); - - $tenantDBKey = "database.connections.{$tenantConnectionName}"; - - $databaseName = $tenant->getDatabaseName(); - $username = $tenant->username; - $password = $tenant->password; + if ($app->configurationIsCached()) { + return; + } - $setConfig = [ - "{$tenantDBKey}.database" => $databaseName, + $newConfig = [ + 'app.instance' => config('app.instance') . '_' . $tenant->id, ]; - if ($username) { - $setConfig["{$tenantDBKey}.username"] = $username; + + if (!isset($tenant->config['cache.stores.cache_settings.prefix'])) { + $newConfig['cache.stores.cache_settings.prefix'] = + 'tenant_id_' . $tenant->id . ':' . $tenant->getOriginalValue('CACHE_SETTING_PREFIX'); } - if ($password) { - $setConfig["{$tenantDBKey}.password"] = $password; + + if (!isset($tenant->config['script-runner-microservice.callback'])) { + $newConfig['script-runner-microservice.callback'] = str_replace( + $tenant->getOriginalValue('APP_URL'), + config('app.url'), + $tenant->getOriginalValue('SCRIPT_MICROSERVICE_CALLBACK') + ); } - config($setConfig); + if (!isset($tenant->config['app.docker_host_url'])) { + // There is no specific override in the tenant's config so set it to the app url + $newConfig['app.docker_host_url'] = config('app.url'); + } - app('db')->extend($tenantConnectionName, function ($config, $name) use ($databaseName, $username, $password) { - $config['database'] = $databaseName; - if ($username) { - $config['username'] = $username; - } - if ($password) { - $config['password'] = $password; + // Set config from the entry in the tenants table + $config = $tenant->config; + foreach ($config as $key => $value) { + if ($key === 'app.key' || $key === 'app.url') { + continue; } + $newConfig[$key] = $value; + } - return app('db.factory')->make($config, $name); - }); - - DB::purge($tenantConnectionName); - - // Octane will have an old `db` instance in the Model::$resolver. - Model::setConnectionResolver(app('db')); + config($newConfig); } } diff --git a/ProcessMaker/Multitenancy/Tenant.php b/ProcessMaker/Multitenancy/Tenant.php index 1b87dd5d5e..4b2b9f091a 100644 --- a/ProcessMaker/Multitenancy/Tenant.php +++ b/ProcessMaker/Multitenancy/Tenant.php @@ -2,14 +2,43 @@ namespace ProcessMaker\Multitenancy; +use Illuminate\Support\Facades\Crypt; +use ProcessMaker\Application; use Spatie\Multitenancy\Models\Tenant as SpatieTenant; class Tenant extends SpatieTenant { + const BOOTSTRAPPED_TENANT = 'bootstrappedTenant'; + protected $guarded = []; + // Non-persistent + public $originalValues = null; + protected $casts = [ 'config' => 'array', 'password' => 'encrypted', ]; + + public static function setBootstrappedTenant(Application $app, ?array $tenantData) + { + $app->instance(self::BOOTSTRAPPED_TENANT, $tenantData); + } + + public static function fromBootstrapper() + { + if (app()->has(self::BOOTSTRAPPED_TENANT)) { + $tenant = (new static())->newFromBuilder(app(self::BOOTSTRAPPED_TENANT)); + $tenant->originalValues = app(self::BOOTSTRAPPED_TENANT)['original_values']; + + return $tenant; + } + + return null; + } + + public function getOriginalValue($key = null) + { + return $this->originalValues[$key] ?? null; + } } diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php new file mode 100644 index 0000000000..8e874a8da4 --- /dev/null +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -0,0 +1,177 @@ +env('MULTITENANCY')) { + return; + } + + // We need to save the original values for running horizon + $this->saveOriginalValues(); + + $tenantData = null; + + // Try to find tenant by ID first if TENANT env var is set + $envTenant = $this->env('TENANT'); + if ($envTenant) { + $tenantData = $this->executeQuery('SELECT * FROM tenants WHERE id = ?', [$envTenant]); + } else { + $request = Request::capture(); + $host = $request->getHost(); + $tenantData = $this->executeQuery('SELECT * FROM tenants WHERE domain = ? LIMIT 1', [$host]); + } + + if (!$tenantData) { + Tenant::setBootstrappedTenant($app, null); + + return; + } + $this->setTenantEnvironmentVariables($app, $tenantData); + + // Use tenant's translation files. Doing this here so it's available in cached filesystems.php + $app->useLangPath(resource_path('lang/tenant_' . $tenantData['id'])); + + $tenantData['original_values'] = $this->getOriginalValue(); + Tenant::setBootstrappedTenant($app, $tenantData); + } + + private function setTenantEnvironmentVariables($app, $tenantData) + { + // Additional configs are set in SwitchTenant.php + + $tenantId = $tenantData['id']; + $config = json_decode($tenantData['config'], true); + + $this->set('APP_CONFIG_CACHE', $app->basePath('storage/tenant_' . $tenantId . '/config.php')); + // Do not override packages cache path for now. Wait until the License service is updated. + // $this->set('APP_PACKAGES_CACHE', $app->basePath('storage/tenant_' . $tenantId . '/packages.php')); + $this->set('LARAVEL_STORAGE_PATH', $app->basePath('storage/tenant_' . $tenantId)); + $this->set('APP_URL', $config['app.url']); + $this->set('APP_KEY', $this->decrypt($config['app.key'])); + $this->set('DB_DATABASE', $tenantData['database']); + $this->set('DB_USERNAME', $tenantData['username'] ?? $this->getOriginalValue('DB_USERNAME')); + + $encryptedPassword = $tenantData['password']; + $password = null; + if ($encryptedPassword) { + $password = $this->decrypt($encryptedPassword); + } else { + $password = $this->getOriginalValue('DB_PASSWORD'); + } + + $this->set('DB_PASSWORD', $password); + $this->set('REDIS_PREFIX', $this->getOriginalValue('REDIS_PREFIX') . 'tenant-' . $tenantId . ':'); + $this->set('LOG_PATH', $app->basePath('storage/tenant_' . $tenantId . '/logs/processmaker.log')); + } + + private function saveOriginalValues() + { + if ($this->env('ORIGINAL_VALUES')) { + return; + } + $toSave = [ + 'APP_URL', + 'APP_KEY', + 'DB_USERNAME', + 'DB_PASSWORD', + 'REDIS_PREFIX', + 'CACHE_SETTING_PREFIX', + 'SCRIPT_MICROSERVICE_CALLBACK', + ]; + $values = []; + foreach ($toSave as $key) { + $values[$key] = $this->env($key); + } + $this->set('ORIGINAL_VALUES', serialize($values)); + } + + private function getOriginalValue($key = null) + { + if (!$this->originalValues) { + $this->originalValues = unserialize($this->env('ORIGINAL_VALUES')); + } + if (!$key) { + return $this->originalValues; + } + + return $this->originalValues[$key]; + } + + private function env($key, $default = null) + { + return Env::get($key, $default); + } + + private function set($key, $value) + { + Env::getRepository()->set($key, $value); + } + + private function decrypt($value) + { + if (!$this->encrypter) { + $key = $this->getOriginalValue('APP_KEY'); + $landlordKey = base64_decode(substr($key, 7)); + $this->encrypter = new Encrypter($landlordKey, 'AES-256-CBC'); + } + + return $this->encrypter->decryptString($value); + } + + private function getLandlordDbConfig(): array + { + return [ + 'host' => $this->env('DB_HOSTNAME', 'localhost'), + 'port' => $this->env('DB_PORT', '3306'), + 'database' => $this->env('LANDLORD_DB_DATABASE', 'landlord'), + 'username' => $this->env('DB_USERNAME'), + 'password' => $this->env('DB_PASSWORD'), + 'charset' => 'utf8mb4', + ]; + } + + private function getPdo(): PDO + { + if (!$this->pdo) { + $landlordConfig = $this->getLandlordDbConfig(); + $dsn = "mysql:host={$landlordConfig['host']};port={$landlordConfig['port']};dbname={$landlordConfig['database']};charset={$landlordConfig['charset']}"; + $this->pdo = new PDO($dsn, $landlordConfig['username'], $landlordConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + + return $this->pdo; + } + + private function executeQuery($query, $params = []) + { + $stmt = $this->getPdo()->prepare($query); + $stmt->execute($params); + + return $stmt->fetch(); + } +} diff --git a/ProcessMaker/Multitenancy/TenantFinder.php b/ProcessMaker/Multitenancy/TenantFinder.php index bfc986f17c..5dcbb801f9 100644 --- a/ProcessMaker/Multitenancy/TenantFinder.php +++ b/ProcessMaker/Multitenancy/TenantFinder.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Env; +use ProcessMaker\Multitenancy\Tenant; use Spatie\Multitenancy\Contracts\IsTenant; use Spatie\Multitenancy\TenantFinder\DomainTenantFinder; @@ -11,6 +12,14 @@ class TenantFinder extends DomainTenantFinder { public function findForRequest(Request $request): ?IsTenant { + if (!config('app.multitenancy')) { + return null; + } + + if ($tenant = Tenant::fromBootstrapper()) { + return $tenant; + } + $tenant = null; $message = null; diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 0e2710709e..fd93a0b222 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -18,7 +18,6 @@ use Illuminate\Support\Facades\URL; use Laravel\Dusk\DuskServiceProvider; use Laravel\Horizon\Horizon; -use Laravel\Passport\Passport; use Lavary\Menu\Menu; use ProcessMaker\Cache\Settings\SettingCacheManager; use ProcessMaker\Console\Migration\ExtendedMigrateCommand; @@ -90,71 +89,6 @@ public function boot(): void Route::pushMiddlewareToGroup('api', HandleEtag::class); // Hook after service providers boot self::$bootTime = (microtime(true) - self::$bootStart) * 1000; // Convert to milliseconds - - // Only run this for console commands so we dont query the Tenants database for each request. - // This sets up individual supervisors for each tenant so that one tenant does not block - // the queue for another tenant. This must be done here instead of SwitchTenant.php because - // there is a single horizon instance for all tenants. - if ($this->app->runningInConsole() && config('app.multitenancy') && $this->horizonTenantsNotSet()) { - $tenants = Tenant::all(); - $config = config('horizon.environments'); - $config = $this->addTenantSupervisors($config, $tenants); - config(['horizon.environments' => $config]); - } - } - - private function horizonTenantsNotSet(): bool - { - $firstKey = array_keys(config('horizon.environments.production'))[0]; - if (str_starts_with($firstKey, 'tenant')) { - // Already cached - return false; - } - - return true; - } - - private function addTenantSupervisors(array $config, TenantCollection $tenants): array - { - $tenantsConfigById = $tenants->mapWithKeys(function ($tenant) { - return [$tenant->id => $tenant->config]; - }); - - foreach ($config as $env => &$supervisors) { - $newSupervisors = []; - - foreach ($supervisors as $supervisorName => $settings) { - foreach ($tenants as $tenant) { - $tenantId = $tenant->id; - $tenantSupervisorName = "tenant-{$tenantId}-{$supervisorName}"; - - // Copy original settings - $tenantSettings = $settings; - - // Set tenant-specific settings - $tenantConfig = $tenantsConfigById[$tenantId]; - foreach (['balance', 'tries', 'timeout', 'minProcesses', 'maxProcesses'] as $key) { - $value = Arr::get($tenantConfig, "horizon.{$supervisorName}.{$key}", null); - if ($value !== null) { - $tenantSettings[$key] = $value; - } - } - - // Prepend tenant ID to each queue - if (isset($tenantSettings['queue']) && is_array($tenantSettings['queue'])) { - $tenantSettings['queue'] = array_map(function ($queue) use ($tenantId) { - return "tenant-{$tenantId}-{$queue}"; - }, $tenantSettings['queue']); - } - - $newSupervisors[$tenantSupervisorName] = $tenantSettings; - } - } - - $supervisors = $newSupervisors; - } - - return $config; } public function register(): void @@ -259,6 +193,10 @@ public function register(): void // Miscellaneous vendor customization static::configureVendors(); + $this->app->singleton(PackageManifest::class, fn () => new LicensedPackageManifest( + new Filesystem, $this->app->basePath(), $this->app->getCachedPackagesPath() + )); + $this->app->extend(MigrateCommand::class, function () { return new ExtendedMigrateCommand( app('migrator'), @@ -587,15 +525,27 @@ private function setCurrentTenantForConsoleCommands(): void return; } - $tenantId = Env::get('TENANT'); - if ($tenantId) { - $tenant = Tenant::findOrFail($tenantId); + if (config('app.multitenancy') === false) { + event(new TenantResolved(null)); + + return; + } + + if ($tenant = Tenant::fromBootstrapper()) { $tenant->makeCurrent(); - } elseif (config('app.multitenancy') === false) { - // This is expected if multitenancy is disabled. - // Call the TenantResolved event with null to continue loading the app. + + return; + } + + $tenantId = Env::get('TENANT'); + if (!$tenantId) { event(new TenantResolved(null)); + + return; } + + $tenant = Tenant::findOrFail($tenantId); + $tenant->makeCurrent(); } /** diff --git a/config/filesystems.php b/config/filesystems.php index e79015155f..ba60def6ff 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -126,13 +126,6 @@ 'root' => lang_path(), ], - // Note, this storage path is for all tenants. It is not modififed in SwitchTenant.php - // Used for license.json since, for now, its the same for all tenants - 'root' => [ - 'driver' => 'local', - 'root' => storage_path(), - ], - // Others declared in packages // - translations - package-translations // - 'filesystems.disks.install' configured on the fly diff --git a/config/logging.php b/config/logging.php index 8f68eeced3..075376e46c 100644 --- a/config/logging.php +++ b/config/logging.php @@ -62,14 +62,14 @@ 'single' => [ 'driver' => 'single', - 'path' => base_path('storage/logs/processmaker.log'), + 'path' => env('LOG_PATH', base_path('storage/logs/processmaker.log')), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'daily' => [ 'driver' => 'daily', - 'path' => base_path('storage/logs/processmaker.log'), + 'path' => env('LOG_PATH', base_path('storage/logs/processmaker.log')), 'level' => env('LOG_LEVEL', 'debug'), 'days' => 7, 'replace_placeholders' => true, diff --git a/tests/Feature/LicenseCommandsTest.php b/tests/Feature/LicenseCommandsTest.php index 171ba2fb09..5e43b77939 100644 --- a/tests/Feature/LicenseCommandsTest.php +++ b/tests/Feature/LicenseCommandsTest.php @@ -17,14 +17,14 @@ class LicenseCommandsTest extends TestCase protected function setUp(): void { parent::setUp(); - Storage::fake('root'); + Storage::fake('local'); } protected function tearDown(): void { // remove the license.json file if it exists - if (Storage::disk('root')->exists('license.json')) { - Storage::disk('root')->delete('license.json'); + if (Storage::disk('local')->exists('license.json')) { + Storage::disk('local')->delete('license.json'); } Cache::forget(LicensedPackageManifest::EXPIRE_CACHE_KEY); // run package:discover @@ -43,7 +43,7 @@ public function testLicenseUpdateFromLocalPath() $this->artisan('processmaker:license-update', ['licenseFile' => $licenseFilePath]) ->assertExitCode(0); - $this->assertTrue(Storage::disk('root')->exists('license.json')); + $this->assertTrue(Storage::disk('local')->exists('license.json')); } public function testLicenseUpdateWithInvalidContent() @@ -59,26 +59,26 @@ public function testLicenseUpdateWithInvalidContent() public function testLicenseRemoveConfirmation() { - Storage::disk('root')->put('license.json', 'sample content'); + Storage::disk('local')->put('license.json', 'sample content'); $this->artisan('processmaker:license-remove') ->expectsQuestion('Are you sure you want to remove the license.json file?', false) ->expectsOutput('Operation cancelled. license.json was not removed.') ->assertExitCode(0); - $this->assertTrue(Storage::disk('root')->exists('license.json')); + $this->assertTrue(Storage::disk('local')->exists('license.json')); } public function testLicenseRemove() { - Storage::disk('root')->put('license.json', '{"expires_at": "2023-12-31", "packages": []}'); + Storage::disk('local')->put('license.json', '{"expires_at": "2023-12-31", "packages": []}'); $this->artisan('processmaker:license-remove') ->expectsQuestion('Are you sure you want to remove the license.json file?', true) ->expectsOutput('license.json removed successfully!') ->assertExitCode(0); - $this->assertFalse(Storage::disk('root')->exists('license.json')); + $this->assertFalse(Storage::disk('local')->exists('license.json')); } public function testLicenseRemoveNonExistent() diff --git a/tests/Feature/LicenseTest.php b/tests/Feature/LicenseTest.php index abf6038557..dd67013ef2 100644 --- a/tests/Feature/LicenseTest.php +++ b/tests/Feature/LicenseTest.php @@ -24,14 +24,14 @@ class LicenseTest extends TestCase protected function setUp(): void { parent::setUp(); - Storage::fake('root'); + Storage::fake('local'); } protected function tearDown(): void { // remove the license.json file if it exists - if (Storage::disk('root')->exists('license.json')) { - Storage::disk('root')->delete('license.json'); + if (Storage::disk('local')->exists('license.json')) { + Storage::disk('local')->delete('license.json'); } Cache::forget(LicensedPackageManifest::EXPIRE_CACHE_KEY); @@ -54,7 +54,7 @@ public function testLicense() ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); $packageManifest = $this->app->make(PackageManifest::class); $packagesToIgnore = $packageManifest->loadPackagesToIgnore(); @@ -84,7 +84,7 @@ public function testExpiredLicense() ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); Carbon::setTestNow(Carbon::now()->addDays(31)); @@ -118,7 +118,7 @@ public function testProviderWithLicense() 'package-projects', ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); Artisan::call('package:discover'); @@ -141,7 +141,7 @@ public function testProviderWithExpiredLicense() 'package-projects', ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); Artisan::call('package:discover'); diff --git a/tests/Feature/MediaConfigTest.php b/tests/Feature/MediaConfigTest.php index 1f9496c5d6..abb864d126 100644 --- a/tests/Feature/MediaConfigTest.php +++ b/tests/Feature/MediaConfigTest.php @@ -23,7 +23,7 @@ public function testMediaMaxFileSize() $user = $processRequest->user; // Ensure storage disk is available - Storage::fake('root'); + Storage::fake('local'); // Test file within size limit (500KB) $validFilePath = storage_path('app/test_valid.txt'); diff --git a/tests/Model/DevLinkTest.php b/tests/Model/DevLinkTest.php index c01bfe0a52..4fdd994baa 100644 --- a/tests/Model/DevLinkTest.php +++ b/tests/Model/DevLinkTest.php @@ -57,7 +57,7 @@ public function testGetOauthRedirectUrl() public function testInstallRemoteBundle() { - Storage::fake('root'); + Storage::fake('local'); $screen1 = Screen::factory()->create(['title' => 'Screen 1']); $screen2 = Screen::factory()->create(['title' => 'Screen 2']); @@ -152,7 +152,7 @@ public function testRemoteBundles() public function testUpdateBundle() { - Storage::fake('root'); + Storage::fake('local'); // Remote Instance $screen = Screen::factory()->create(['title' => 'Screen Name']);