diff --git a/.dev/.env b/.dev/.env index 22f162bd0a..4f0d54cd8a 100644 --- a/.dev/.env +++ b/.dev/.env @@ -25,11 +25,11 @@ LEAN_ENABLE_MENU_TYPE = false # Enable to specifiy menu on LEAN_SESSION_PASSWORD = '3evBlq9zdUEuzKvVJHWWx3QzsQhturBApxwcws2m' #Salting sessions. Replace with a strong password LEAN_SESSION_EXPIRATION = 28800 # How many seconds after inactivity should we logout? 28800seconds = 8hours LEAN_LOG_PATH = '' # Default Log Path (including filename), if not set /logs/error.log will be used -LEAN_PLUGINS = 'motivationalquotes' # Comma separated list of plugins to load +LEAN_PLUGINS = '' # Comma separated list of plugins to load ## Look & Feel, these settings are available in the UI and can be overwritten there. -LEAN_LOGO_PATH = '/dist/images/logo.svg' # Default logo path, can be changed later -LEAN_PRINT_LOGO_URL = '/dist/images/logo.jpg' # Default logo URL use for printing (must be jpg or png format) +LEAN_LOGO_PATH = '/dist/images/logo.svg' # Default logo path, can be changed later +LEAN_PRINT_LOGO_URL = '/dist/images/logo.jpg' # Default logo URL use for printing (must be jpg or png format) LEAN_DEFAULT_THEME = 'default' # Default theme LEAN_PRIMARY_COLOR = '#1b75bb' # Primary Theme color LEAN_SECONDARY_COLOR = '#81B1A8' # Secondary Theme Color diff --git a/.dev/dockerfile b/.dev/dockerfile index 7caa78d593..b284962a57 100644 --- a/.dev/dockerfile +++ b/.dev/dockerfile @@ -7,8 +7,9 @@ RUN apt update && apt install -f -y libonig-dev libcurl4-openssl-dev libxml2-de libfreetype6-dev libjpeg62-turbo-dev libpng-dev apt-utils vim curl sqlite3\ openssl RUN pecl install xdebug -RUN docker-php-ext-install mysqli pdo_mysql mbstring exif pcntl pdo bcmath opcache ldap +RUN docker-php-ext-install mysqli pdo_mysql mbstring exif pcntl pdo bcmath opcache ldap zip +RUN docker-php-ext-enable zip RUN docker-php-ext-configure gd --enable-gd --with-jpeg=/usr/include/ --with-freetype --with-jpeg RUN docker-php-ext-install gd RUN docker-php-ext-enable xdebug diff --git a/.phpactor.json b/.phpactor.json index 3181c8df51..06a9670c82 100644 --- a/.phpactor.json +++ b/.phpactor.json @@ -1,4 +1,5 @@ { "$schema": "/Users/josephroberts/.local/share/nvim/mason/packages/phpactor/phpactor.schema.json", - "php_code_sniffer.enabled": false + "php_code_sniffer.enabled": false, + "language_server_phpstan.enabled": true } \ No newline at end of file diff --git a/app/Command/MigrateCommand.php b/app/Command/MigrateCommand.php index fc0a66c827..baf10bc205 100644 --- a/app/Command/MigrateCommand.php +++ b/app/Command/MigrateCommand.php @@ -46,8 +46,8 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - define('BASE_URL', ""); - define('CURRENT_URL', ""); + ! defined('BASE_URL') && define('BASE_URL', ""); + ! defined('CURRENT_URL') && define('CURRENT_URL', ""); $install = app()->make(Install::class); $io = new SymfonyStyle($input, $output); @@ -99,8 +99,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->text("Successfully Installed DB"); } $success = $install->updateDB(); - if (!$success) { - throw new Exception("Migration Failed; Please Check Logs"); + if ($success !== true) { + throw new Exception("Migration Failed; See below" . PHP_EOL . implode(PHP_EOL, $success)); } } catch (Exception $ex) { $io->error($ex); diff --git a/app/Core/HtmxController.php b/app/Core/HtmxController.php index ae7cc556e7..cc38faa964 100644 --- a/app/Core/HtmxController.php +++ b/app/Core/HtmxController.php @@ -13,6 +13,7 @@ * * @package leantime * @subpackage core + * @method string|null run() The fallback method to be initialized. */ abstract class HtmxController { @@ -21,6 +22,7 @@ abstract class HtmxController protected IncomingRequest $incomingRequest; protected Template $tpl; protected Response $response; + protected static string $view; /** * constructor - initialize private variables @@ -28,12 +30,12 @@ abstract class HtmxController * @access public * * @param IncomingRequest $incomingRequest The request to be initialized. - * @param template $tpl The template to be initialized. + * @param Template $tpl The template to be initialized. * @throws BindingResolutionException */ public function __construct( IncomingRequest $incomingRequest, - template $tpl + Template $tpl ) { self::dispatch_event('begin'); @@ -69,11 +71,11 @@ private function executeActions(): void $action = Str::camel($this->incomingRequest->query->get('id', 'run')); - if (! method_exists($this, $action)) { - throw new Error("Method $action doesn't exist."); + if (! method_exists($this, $action) && ! method_exists($this, 'run')) { + throw new Error("Method $action doesn't exist and no fallback method."); } - $fragment = $this->$action(); + $fragment = method_exists($this, $action) ? $this->$action() : $this->run(); $this->response = $this->tpl->displayFragment($this::$view, $fragment ?? ''); } diff --git a/app/Core/HttpKernel.php b/app/Core/HttpKernel.php index 5cf2f8b383..0b5348d82d 100644 --- a/app/Core/HttpKernel.php +++ b/app/Core/HttpKernel.php @@ -6,6 +6,8 @@ use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Pipeline\Pipeline; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; class HttpKernel implements HttpKernelContract { @@ -46,7 +48,16 @@ public function handle($request) return $e->getResponse(); } catch (\Throwable $e) { if (! app()->make(Environment::class)->debug) { - return new RedirectResponse(BASE_URL . "/errors/error500", 500); + return new RedirectResponse(BASE_URL . "/errors/error500", 201); + } + + if ($request instanceof HtmxRequest) { + /** @todo Replace with a proper error template for htmx requests */ + return new Response(sprintf( + '', + 'width: 90vw; height: 90vh; z-index: 9999999; position: fixed; top: 5vh; left: 5vh; overflow: scroll', + (new HtmlErrorRenderer(true))->render($e)->getAsString(), + )); } throw $e; diff --git a/app/Core/Language.php b/app/Core/Language.php index b6551f426e..791c822d8a 100644 --- a/app/Core/Language.php +++ b/app/Core/Language.php @@ -359,51 +359,55 @@ public function getBrowserLanguage(): string /** * __ - returns a language specific string * - * @access public - * @param string $index - * @param bool $convertValue If true then if a value has a conversion (i.e.: PHP -> JavaScript) then do it, otherwise return PHP value - * @return string + * @param string $index + * @param bool $convertValue If true then if a value has a conversion (i.e.: PHP -> JavaScript) then do it, otherwise return PHP value + * @param string $default If the index is not found, return this value + * @return string */ - public function __(string $index, bool $convertValue = false): string + public function __(string $index, bool $convertValue = false, $default = ''): string { - if (isset($this->ini_array[$index]) === true) { - $index = trim($index); - - // @TODO: move the date/time format logic into here and have Api/Controllers/I18n call this for each type? - $dateTimeIniSettings = [ - 'language.dateformat', - 'language.jsdateformat', - 'language.timeformat', - 'language.jstimeformat', - 'language.momentJSDate', - ]; - - $dateTimeFormat = $this->getCustomDateTimeFormat(); - - if (in_array($index, $dateTimeIniSettings) && $convertValue) { - $isMoment = stristr($index, 'momentjs') !== false; - $isJs = stristr($index, '.js') !== false; - $isDate = stristr($index, 'date') !== false; - - if ($isJs || $isMoment) { - return $this->convertDateFormatToJS($isDate ? $dateTimeFormat['date'] : $dateTimeFormat['time'], $isMoment); - } else if ($isDate) { - return $this->convertDateFormatToJS($dateTimeFormat['date'], false); - } - } else if ($index === 'language.dateformat') { - return $dateTimeFormat['date']; - } else if ($index === 'language.timeformat') { - return $dateTimeFormat['time']; + if (! isset($this->ini_array[$index])) { + if (! empty($default)) { + return $default; } - return (string) $this->ini_array[$index]; - } else { - if ($this->alert === true) { - return '' . $index . ''; - } else { - return $index; + if ($this->alert) { + return sprintf('%s', $index); } + + return $index; } + + $index = trim($index); + + // @TODO: move the date/time format logic into here and have Api/Controllers/I18n call this for each type? + $dateTimeIniSettings = [ + 'language.dateformat', + 'language.jsdateformat', + 'language.timeformat', + 'language.jstimeformat', + 'language.momentJSDate', + ]; + + $dateTimeFormat = $this->getCustomDateTimeFormat(); + + if (in_array($index, $dateTimeIniSettings) && $convertValue) { + $isMoment = stristr($index, 'momentjs') !== false; + $isJs = stristr($index, '.js') !== false; + $isDate = stristr($index, 'date') !== false; + + if ($isJs || $isMoment) { + return $this->convertDateFormatToJS($isDate ? $dateTimeFormat['date'] : $dateTimeFormat['time'], $isMoment); + } else if ($isDate) { + return $this->convertDateFormatToJS($dateTimeFormat['date'], false); + } + } else if ($index === 'language.dateformat') { + return $dateTimeFormat['date']; + } else if ($index === 'language.timeformat') { + return $dateTimeFormat['time']; + } + + return (string) $this->ini_array[$index]; } /** diff --git a/app/Core/Middleware/Updated.php b/app/Core/Middleware/Updated.php index f62b313281..a3988a03a6 100644 --- a/app/Core/Middleware/Updated.php +++ b/app/Core/Middleware/Updated.php @@ -25,7 +25,7 @@ public function handle(IncomingRequest $request, Closure $next): Response $dbVersion = app()->make(SettingRepository::class)->getSetting('db-version'); $settingsDbVersion = app()->make(AppSettings::class)->dbVersion; - $_SESSION['isUpdated'] = $dbVersion == $settingsDbVersion; + $_SESSION['isUpdated'] ??= $dbVersion == $settingsDbVersion; self::dispatch_event('system_update', ['dbVersion' => $dbVersion, 'settingsDbVersion' => $settingsDbVersion]); diff --git a/app/Domain/Plugins/Controllers/Details.php b/app/Domain/Plugins/Controllers/Details.php index 90958fdc9a..80ac7a7f2b 100644 --- a/app/Domain/Plugins/Controllers/Details.php +++ b/app/Domain/Plugins/Controllers/Details.php @@ -32,13 +32,17 @@ public function get(): Response } /** - * @var \Leantime\Domain\Plugins\Models\MarketplacePlugin[] $versions + * @var \Leantime\Domain\Plugins\Models\MarketplacePlugin|false $plugin */ - $versions = $this->pluginService->getMarketplacePlugin( + $plugin = $this->pluginService->getMarketplacePlugin( $this->incomingRequest->query->get('id'), ); - $this->tpl->assign('versions', $versions); + if (! $plugin) { + return $this->tpl->display('error.error404', 'blank'); + } + + $this->tpl->assign('plugin', $plugin); return $this->tpl->display('plugins.plugindetails', 'blank'); } diff --git a/app/Domain/Plugins/Hxcontrollers/Details.php b/app/Domain/Plugins/Hxcontrollers/Details.php index ffcde8b5be..54c6921b57 100644 --- a/app/Domain/Plugins/Hxcontrollers/Details.php +++ b/app/Domain/Plugins/Hxcontrollers/Details.php @@ -3,6 +3,7 @@ namespace Leantime\Domain\Plugins\Hxcontrollers; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Http\Exceptions\HttpResponseException; use Leantime\Core\HtmxController; use Leantime\Domain\Plugins\Models\MarketplacePlugin; use Leantime\Domain\Plugins\Services\Plugins as PluginService; @@ -39,22 +40,30 @@ public function init( */ public function install(): string { - $pluginModel = app(MarketplacePlugin::class); $pluginProps = $this->incomingRequest->request->all()['plugin']; - collect($pluginProps)->each(fn($value, $key) => $pluginModel->{$key} = $value ?? ''); + $version = $pluginProps['version']; + unset($pluginProps['version']); + $builder = build(new MarketplacePlugin); - if (! empty($pluginModel->identifier)) { - $pluginModel->identifier = Str::studly($pluginModel->identifier); + foreach ($pluginProps as $key => $value) { + $newValue = json_decode(json: $value, flags: JSON_OBJECT_AS_ARRAY); + + if (json_last_error() === JSON_ERROR_NONE) { + $value = $newValue; + } + + $builder->set($key, $value); } - $this->tpl->assign('versions', [$pluginModel->version => $pluginModel]); + $pluginModel = $builder->get(); + + $this->tpl->assign('plugin', $pluginModel); try { - $this->pluginService->installMarketplacePlugin($pluginModel); + $this->pluginService->installMarketplacePlugin($pluginModel, $version); } catch (\Throwable $e) { - Frontcontroller::setResponseCode(200); - $this->tpl->assign('formError', $e->getMessage()); - return 'plugin-installation'; + $this->tpl->assign('formError', $e->getMessage()); + return 'plugin-installation'; } if ($this->pluginService->isPluginEnabled($pluginModel->identifier)) { diff --git a/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php b/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php index 9c7172841f..7e20e6080c 100644 --- a/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php +++ b/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php @@ -37,6 +37,7 @@ public function init( */ public function getlist(): void { + /** @var MarketplacePlugin[] $plugins */ $plugins = $this->pluginService->getMarketplacePlugins( $this->incomingRequest->query->get('page', 1), $this->incomingRequest->query->get('search', ''), diff --git a/app/Domain/Plugins/Models/MarketplacePlugin.php b/app/Domain/Plugins/Models/MarketplacePlugin.php index 2d68576fce..7989a78465 100644 --- a/app/Domain/Plugins/Models/MarketplacePlugin.php +++ b/app/Domain/Plugins/Models/MarketplacePlugin.php @@ -14,13 +14,18 @@ class MarketplacePlugin implements PluginDisplayStrategy public string $excerpt; public string $description; public string $imageUrl; - public array|string $authors; - public string $version; + public string $vendorDisplayName; + public int $vendorId; + public string $vendorEmail; public string $marketplaceUrl; - public ?string $price; + public ?string $startingPrice; + public ?array $pricingTiers; public ?string $license; public ?string $rating; + public ?int $reviewCount; + public array $reviews; public string $marketplaceId; + public array $compatibility; public function getCardDesc(): string { @@ -31,12 +36,28 @@ public function getMetadataLinks(): array { $links = []; - if (! empty($plugin->authors)) { - $author = is_array($plugin->authors) ? $plugin->authors[0] : $plugin->authors; - $links[] = [ + if (! empty($this->vendorDisplayName) && (! empty($this->vendorId) || ! empty($this->vendorEmail))) { + $vendor = [ 'prefix' => __('text.by'), - 'link' => "mailto:{$author->email}", - 'text' => $author->name, + 'display' => $this->vendorDisplayName, + ]; + + $vendor['link'] = ! empty($this->vendorId) ? "/plugins/marketplace?" . http_build_query(['vendor_id' => $this->vendorId]) : "mailto:{$this->vendorEmail}"; + + $links[] = $vendor; + } + + if (! empty($this->startingPrice)) { + $links[] = [ + 'prefix' => __('text.starting_at', 'Starting At'), + 'display' => $this->startingPrice, + ]; + } + + if (! empty($this->rating)) { + $links[] = [ + 'prefix' => __('text.rating', 'Rating: '), + 'display' => $this->rating, ]; } diff --git a/app/Domain/Plugins/Services/Plugins.php b/app/Domain/Plugins/Services/Plugins.php index 2c9ba54e0b..0add6e3ae3 100644 --- a/app/Domain/Plugins/Services/Plugins.php +++ b/app/Domain/Plugins/Services/Plugins.php @@ -390,12 +390,15 @@ public function getMarketplacePlugins(int $page, string $query = ''): array if (isset($pluginArray["data"])) { foreach ($pluginArray["data"] as $plugin) { $plugins[] = build(new MarketplacePlugin()) - ->setIdentifier($plugin['identifier'] ?? '') - ->setName($plugin['post_title'] ?? '') - ->setExcerpt($plugin['excerpt'] ?? '') - ->set('imageUrl', $plugin['featured_image'] ?? '') - /** @todo Send from marketplace **/ - ->setAuthors('') + ->set('identifier', $plugin['identifier'] ?? '') + ->set('name', $plugin['post_title'] ?? '') + ->set('excerpt', $plugin['excerpt'] ?? '') + ->set('imageUrl', $plugin['icon'] ?? '') + ->set('vendorDisplayName', $plugin['vendor'] ?? '') + ->set('vendorId', $plugin['vendor_id'] ?? '') + ->set('vendorEmail', $plugin['vendor_email'] ?? '') + ->set('startingPrice', '$' . ($plugin['price'] ?? '') . (! empty($plugin['sub_interval']) ? '/' . $plugin['sub_interval'] : '')) + ->set('rating', $plugin['rating'] ?? '') ->get(); } } @@ -407,46 +410,49 @@ public function getMarketplacePlugins(int $page, string $query = ''): array * @param string $identifier * @return MarketplacePlugin[] */ - public function getMarketplacePlugin(string $identifier): array + public function getMarketplacePlugin(string $identifier): MarketplacePlugin|false { - return Http::withoutVerifying()->get("$this->marketplaceUrl/ltmp-api/versions/$identifier") - ->collect() - ->mapWithKeys(function ($data, $version) use ($identifier) { - static $count; - $count ??= 0; - - $pluginModel = app()->make(MarketplacePlugin::class); - $pluginModel->identifier = $identifier; - $pluginModel->name = $data['name']; - $pluginModel->excerpt = ''; - $pluginModel->description = $data['description']; - $pluginModel->marketplaceUrl = $data['marketplace_url']; - $pluginModel->thumbnailUrl = $data['thumbnail_url'] ?: ''; - $pluginModel->authors = $data['author']; - $pluginModel->version = $version; - $pluginModel->price = $data['price']; - $pluginModel->license = $data['license']; - $pluginModel->rating = $data['rating']; - $pluginModel->marketplaceId = $data['product_id']; - - return [$count++ => $pluginModel]; - }) - ->all(); + $response = Http::withoutVerifying()->get("$this->marketplaceUrl/ltmp-api/details/$identifier"); + + if (! $response->ok()) { + return false; + } + + $data = $response->json(); + + return build(new MarketplacePlugin) + ->set('identifier', $identifier ?? '') + ->set('name', $data['name'] ?? '') + ->set('icon', $data['icon'] ?? '') + ->set('description', nl2br($data['description'] ?? '')) + ->set('marketplaceUrl', $data['marketplaceUrl'] ?? '') + ->set('vendorId', (int) $data['vendor']['id'] ?? null) + ->set('vendorDisplayName', $data['vendor']['name'] ?? '') + ->set('rating', $data['reviews']['average'] ?? 'N/A') + ->set('reviewCount', $data['reviews']['count'] ?? 0) + ->set('reviews', $data['reviews']['list']) + ->set('marketplaceId', $data['productId']) + ->set('pricingTiers', $data['tiers']) + ->set('categories', $data['categories'] ?? []) + ->set('tags', $data['tags'] ?? []) + ->set('compatibility', $data['compatibility'] ?? []) + ->get(); } /** * @param MarketplacePlugin $plugin + * @param string $version * @return void * @throws Illuminate\Http\Client\RequestException|Exception */ - public function installMarketplacePlugin(MarketplacePlugin $plugin): void + public function installMarketplacePlugin(MarketplacePlugin $plugin, string $version): void { $response = Http::withoutVerifying()->withHeaders([ 'X-License-Key' => $plugin->license, 'X-Instance-Id' => $this->settingsService->getCompanyId(), 'X-User-Count' => $this->usersService->getNumberOfUsers(activeOnly: true, includeApi: false), ]) - ->get("{$this->marketplaceUrl}/ltmp-api/download/{$plugin->marketplaceId}"); + ->get("{$this->marketplaceUrl}/ltmp-api/download/{$plugin->identifier}/{$version}"); $response->throwIf(in_array(true, [ ! $response->ok(), @@ -455,22 +461,18 @@ public function installMarketplacePlugin(MarketplacePlugin $plugin): void $filename = $response->header('Content-Disposition'); $filename = substr($filename, strpos($filename, 'filename=') + 9); + $foldername = Str::studly(basename($filename, '.zip')); + $filename = Str::finish($foldername, '.zip'); - if (! str_starts_with($filename, $plugin->identifier)) { - throw new \Exception('Wrong file downloaded'); - } - - if ( - ! file_put_contents( - $temporaryFile = Str::finish(sys_get_temp_dir(), '/') . $filename, - $response->body() - ) - ) { + if (! file_put_contents( + $temporaryFile = Str::finish(sys_get_temp_dir(), '/') . $filename, + $response->body() + )) { throw new \Exception('Could not download plugin'); } if ( - is_dir($pluginDir = "{$this->pluginDirectory}{$plugin->identifier}") + is_dir($pluginDir = "{$this->pluginDirectory}{$foldername}") && ! File::deleteDirectory($pluginDir) ) { throw new \Exception('Could not remove existing plugin'); @@ -503,7 +505,7 @@ public function installMarketplacePlugin(MarketplacePlugin $plugin): void unlink($temporaryFile); # read the composer.json content from the plugin phar file - $pluginModel = $this->createPluginFromComposer($plugin->identifier, $plugin->license); + $pluginModel = $this->createPluginFromComposer($foldername, $plugin->license); if (! $this->pluginRepository->addPlugin($pluginModel)) { throw new \Exception('Could not add plugin to database'); diff --git a/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php b/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php index 584b377aaa..29f14598e3 100644 --- a/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php +++ b/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php @@ -1,4 +1,4 @@ -